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()