v0.166.0
  1import fcntl
  2import os
  3import signal
  4import subprocess
  5import sys
  6import time
  7from pathlib import Path
  8from typing import Any
  9
 10from .poncho.manager import Manager as PonchoManager
 11from .poncho.printer import Printer
 12from .state import checkout_state_path, find_project_root
 13
 14
 15def _pid_is_alive(pid: int) -> bool:
 16    """Return True if a process with *pid* currently exists."""
 17    try:
 18        os.kill(pid, 0)  # Signal 0 checks for existence – it does not kill.
 19    except OSError:
 20        return False
 21    return True
 22
 23
 24class Supervisor:
 25    """A single-instance, long-running dev process group.
 26
 27    Only one supervisor may run per project. That's enforced by holding an
 28    exclusive advisory lock (``flock``) on the pidfile for the *entire* life of
 29    the process: a second supervisor simply fails to take the lock and bows out,
 30    and the kernel releases the lock when the holding process exits — so a crash
 31    can't leave a stale lock behind. The pid is written into the file too, but
 32    only so other commands can identify and signal the running supervisor — it
 33    is not what guards against duplicates.
 34    """
 35
 36    # Filename this supervisor records its pid under, e.g. "dev.pid".
 37    state_filename: str
 38    log_dir: Path
 39    # Foreground command that re-runs this supervisor, e.g. ["dev", "services"].
 40    background_command: list[str]
 41    # Human label for "already running" warnings, e.g. "Services".
 42    display_name: str
 43
 44    @classmethod
 45    def pidfile_path(cls) -> Path:
 46        """Where this checkout records which process owns the supervisor slot.
 47
 48        Kept beside the checkout's other facts rather than in its `.plain`, so
 49        two worktrees that end up sharing one can't each report the other's
 50        server as already running — see `plain.dev.state`.
 51
 52        Derived on use rather than at import, because it depends on the working
 53        directory: as a class attribute it would freeze before the process has
 54        finished deciding where it is, while reading like a constant.
 55        """
 56        return checkout_state_path(find_project_root(Path.cwd())) / cls.state_filename
 57
 58    def __init__(self):
 59        self.pid = os.getpid()
 60        self._lock_fd: int | None = None
 61        self.log_path: Path | None = None
 62        self.printer: Printer | None = None
 63        self.poncho: PonchoManager | None = None
 64
 65    # ------------------------------------------------------------------
 66    # Reads (pure, lock-free – cheap enough for the per-command hot path)
 67    # ------------------------------------------------------------------
 68    @classmethod
 69    def read_pidfile(cls) -> int | None:
 70        """Return the PID recorded in the pidfile (or ``None``)."""
 71        try:
 72            return int(cls.pidfile_path().read_text())
 73        except ValueError, OSError:
 74            # Missing, empty (released), or partial – treat as absent.
 75            return None
 76
 77    @classmethod
 78    def running_pid(cls) -> int | None:
 79        """Return a *running* supervisor PID, or ``None`` if none is alive."""
 80        pid = cls.read_pidfile()
 81        if pid is None or not _pid_is_alive(pid):
 82            return None
 83        return pid
 84
 85    @classmethod
 86    def already_running_message(cls, pid: int | None) -> str:
 87        """The single source of truth for the 'slot is taken' warning."""
 88        return f"{cls.display_name} already running (pid={pid})"
 89
 90    # ------------------------------------------------------------------
 91    # Single-instance ownership (the lock is held for our whole lifetime)
 92    # ------------------------------------------------------------------
 93    def acquire(self) -> bool:
 94        """Claim sole ownership for this process's lifetime.
 95
 96        Returns ``True`` if we now hold it, ``False`` if another live supervisor
 97        already does (in which case the caller must not start – a second one
 98        would collide on shared services like the database). A pidfile left by a
 99        dead supervisor is reclaimed automatically: its lock is already gone.
100        """
101        pidfile = self.pidfile_path()
102        pidfile.parent.mkdir(parents=True, exist_ok=True)
103        fd = os.open(pidfile, os.O_CREAT | os.O_RDWR, 0o644)
104        try:
105            fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
106        except OSError:
107            os.close(fd)
108            return False
109
110        os.ftruncate(fd, 0)
111        os.write(fd, str(self.pid).encode())
112        self._lock_fd = fd  # Held (open) until release() / process exit.
113        return True
114
115    def release(self) -> None:
116        """Release ownership: clear the recorded pid and drop the lock."""
117        if self._lock_fd is None:
118            return
119        os.ftruncate(self._lock_fd, 0)
120        os.close(self._lock_fd)  # Closing the fd releases the flock.
121        self._lock_fd = None
122
123    @classmethod
124    def spawn_background(cls, *extra_args: str) -> int:
125        """Start this supervisor detached in the background; return its pid."""
126        proc = subprocess.Popen(
127            [sys.executable, "-m", "plain", *cls.background_command, *extra_args],
128            start_new_session=True,
129            stdout=subprocess.DEVNULL,
130            stderr=subprocess.DEVNULL,
131        )
132        return proc.pid
133
134    @classmethod
135    def _has_live_owner(cls) -> bool:
136        """True if a live supervisor currently holds the lock.
137
138        A read-only probe: it does not write to or truncate the pidfile, so it
139        can't be misread by a concurrent command (unlike claiming the lock).
140        """
141        try:
142            fd = os.open(cls.pidfile_path(), os.O_RDONLY)
143        except OSError:
144            return False
145        try:
146            fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
147        except OSError:
148            return True  # Someone live holds it.
149        else:
150            fcntl.flock(fd, fcntl.LOCK_UN)
151            return False
152        finally:
153            os.close(fd)
154
155    def stop_process(self) -> None:
156        """Terminate the running supervisor, if one actually holds the lock."""
157        pid = self.read_pidfile()
158        if pid is None:
159            return
160
161        # If nobody holds the lock, no supervisor is alive — the recorded pid is
162        # stale (and could even be a reused, unrelated pid), so don't signal it.
163        if not self._has_live_owner():
164            return
165
166        try:
167            os.kill(pid, signal.SIGTERM)
168        except OSError:
169            return  # Already gone.
170
171        deadline = time.time() + 10
172        while time.time() < deadline:
173            if not _pid_is_alive(pid):
174                return  # Exited gracefully; it cleared its own pidfile.
175            time.sleep(0.1)
176
177        try:
178            os.kill(pid, signal.SIGKILL)
179        except OSError:
180            pass
181
182    # ------------------------------------------------------------------
183    # Logging / Poncho helpers
184    # ------------------------------------------------------------------
185    def prepare_log(self) -> Path:
186        """Create the log directory and return a path for *this* run."""
187        self.log_dir.mkdir(parents=True, exist_ok=True)
188
189        # Keep the 5 most recent log files.
190        logs = sorted(
191            self.log_dir.glob("*.log"),
192            key=lambda p: p.stat().st_mtime,
193            reverse=True,
194        )
195        for old in logs[5:]:
196            old.unlink(missing_ok=True)
197
198        self.log_path = self.log_dir / f"{self.pid}.log"
199        return self.log_path
200
201    def init_poncho(self, print_func: Any) -> PonchoManager:
202        """Return a :class:`~plain.dev.poncho.manager.Manager` instance."""
203        if self.log_path is None:
204            self.prepare_log()
205
206        self.printer = Printer(print_func, log_file=self.log_path)
207        self.poncho = PonchoManager(printer=self.printer)
208        return self.poncho
209
210    def close(self) -> None:
211        if self.printer:
212            self.printer.close()