Coverage for src/lilbee/providers/fleet/child_guard.py: 100%

87 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""Bind a spawned llama-swap's lifetime to this process, so a crash cannot orphan it. 

2 

3A graceful exit tears the fleet down and a stale engine is reaped on the next 

4launch; this closes the window between, when lilbee dies without cleanup (SIGKILL, 

5segfault, closed terminal). Each platform binds differently and falls back to the 

6next-launch reap when its primitive is unavailable, so a failure here never fails 

7a spawn: 

8 

9* Linux: ``PR_SET_PDEATHSIG``, set in the preexec of one process-lifetime thread 

10 (the signal binds to the forking thread, not the process). 

11* Windows: a kill-on-close job object whose handle is held for the process life. 

12* Elsewhere (macOS, any POSIX host without prctl): a death pipe (see 

13 :func:`_watch_via_death_pipe`). 

14 

15``processfamily`` covers only prctl+job (needs pywin32, skips macOS), so the 

16syscalls stay custom; the executor is the stdlib's. 

17""" 

18 

19from __future__ import annotations 

20 

21import contextlib 

22import ctypes 

23import logging 

24import os 

25import subprocess 

26import sys 

27import threading 

28from concurrent.futures import ThreadPoolExecutor 

29from typing import TYPE_CHECKING, Any 

30 

31if TYPE_CHECKING: 

32 from collections.abc import Callable 

33 

34log = logging.getLogger(__name__) 

35 

36# prctl(2) PR_SET_PDEATHSIG=1; SIGTERM so llama-swap runs its own shutdown. 

37_PR_SET_PDEATHSIG = 1 

38_PDEATHSIG = 15 

39 

40# Resolved at import so the post-fork child touches an already-loaded handle: a 

41# dlopen after fork can deadlock on a lock a sibling thread holds. 

42_libc: ctypes.CDLL | None = None 

43if sys.platform.startswith("linux"): # pragma: no cover - Linux only 

44 try: 

45 _libc = ctypes.CDLL("libc.so.6", use_errno=True) 

46 except OSError: 

47 _libc = None 

48 

49# Windows job-object constants (winnt.h). 

50_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 

51_JOB_OBJECT_EXTENDED_LIMIT_CLASS = 9 

52_PROCESS_SET_QUOTA = 0x0100 

53_PROCESS_TERMINATE = 0x0001 

54 

55# Live death-pipe write ends, keyed by guarded pid. os.pipe() returns bare fds the 

56# GC never closes, so this is the only handle to close one deliberately (on child 

57# stop) or leave for the kernel to close on our death (which fires the binding). 

58_death_pipe_write_fds: dict[int, int] = {} 

59 

60 

61def bind_lifetime_to_parent(parent_pid: int) -> None: 

62 """Bind this process's lifetime to *parent_pid*, where the kernel supports it. 

63 

64 For a process lilbee spawned that holds resources of its own (an ingest 

65 worker owns a GPU fleet): orphaning one leaves the card allocated with 

66 nothing left to release it. A no-op off Linux. Exits when the parent is 

67 already gone, which the death signal alone would miss. 

68 """ 

69 if _libc is not None: 

70 _libc.prctl(_PR_SET_PDEATHSIG, _PDEATHSIG, 0, 0, 0) 

71 if os.getppid() != parent_pid: 

72 raise SystemExit(1) 

73 

74 

75def _make_pdeathsig_preexec(parent_pid: int) -> Callable[[], None] | None: 

76 """A preexec binding the child's death to *parent_pid*, or None if libc is absent. 

77 

78 Compares against the real pid, not 1, so a lilbee running as pid 1 (a container 

79 without an init shim) is not mistaken for a dead parent. 

80 """ 

81 if _libc is None: 

82 return None 

83 libc = _libc 

84 

85 def _set_pdeathsig() -> None: 

86 # In the forked child pre-exec: touch only pre-resolved handles, no alloc. 

87 libc.prctl(_PR_SET_PDEATHSIG, _PDEATHSIG, 0, 0, 0) 

88 if os.getppid() != parent_pid: 

89 os._exit(1) 

90 

91 return _set_pdeathsig 

92 

93 

94class _LifetimeSpawner: 

95 """Runs spawns on one process-lifetime thread so PR_SET_PDEATHSIG binds to it. 

96 

97 The death signal binds to the forking thread, so a one-worker 

98 ``ThreadPoolExecutor`` (reused for every spawn, stopped only at exit or the 

99 test-only ``close``) keeps that thread alive for the process. 

100 """ 

101 

102 def __init__(self) -> None: 

103 self._executor: ThreadPoolExecutor | None = None 

104 self._lock = threading.Lock() 

105 

106 def spawn(self, *args: Any, **kwargs: Any) -> subprocess.Popen[Any]: 

107 with self._lock: 

108 if self._executor is None: 

109 self._executor = ThreadPoolExecutor( 

110 max_workers=1, thread_name_prefix="fleet-spawner" 

111 ) 

112 executor = self._executor 

113 return executor.submit(subprocess.Popen, *args, **kwargs).result() 

114 

115 def close(self) -> None: 

116 """Stop the spawner thread. Test-only; in production it lives forever.""" 

117 with self._lock: 

118 executor, self._executor = self._executor, None 

119 if executor is not None: 

120 executor.shutdown(wait=True) 

121 

122 

123_spawner = _LifetimeSpawner() 

124 

125 

126def _assign_to_kill_on_close_job(pid: int) -> None: # pragma: no cover - Windows only 

127 """Put *pid* in a kill-on-close job object. 

128 

129 The job handle is deliberately leaked: holding it open is what kills the child 

130 when this process ends, and the OS reclaims it then. 

131 """ 

132 # windll is a Windows-only loader absent from other platforms' stubs; the 

133 # Any alias keeps the checker quiet without an attr-defined ignore. 

134 ct: Any = ctypes 

135 kernel32 = ct.windll.kernel32 

136 

137 class JobObjectBasicLimitInformation(ctypes.Structure): 

138 _fields_ = [ 

139 ("PerProcessUserTimeLimit", ctypes.c_int64), 

140 ("PerJobUserTimeLimit", ctypes.c_int64), 

141 ("LimitFlags", ctypes.c_uint32), 

142 ("MinimumWorkingSetSize", ctypes.c_size_t), 

143 ("MaximumWorkingSetSize", ctypes.c_size_t), 

144 ("ActiveProcessLimit", ctypes.c_uint32), 

145 ("Affinity", ctypes.c_size_t), 

146 ("PriorityClass", ctypes.c_uint32), 

147 ("SchedulingClass", ctypes.c_uint32), 

148 ] 

149 

150 class IoCounters(ctypes.Structure): 

151 _fields_ = [(name, ctypes.c_uint64) for name in ("r", "w", "o", "rb", "wb", "ob")] 

152 

153 class JobObjectExtendedLimitInformation(ctypes.Structure): 

154 _fields_ = [ 

155 ("BasicLimitInformation", JobObjectBasicLimitInformation), 

156 ("IoInfo", IoCounters), 

157 ("ProcessMemoryLimit", ctypes.c_size_t), 

158 ("JobMemoryLimit", ctypes.c_size_t), 

159 ("PeakProcessMemoryUsed", ctypes.c_size_t), 

160 ("PeakJobMemoryUsed", ctypes.c_size_t), 

161 ] 

162 

163 job = kernel32.CreateJobObjectW(None, None) 

164 if not job: 

165 raise OSError("CreateJobObjectW failed") 

166 info = JobObjectExtendedLimitInformation() 

167 info.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 

168 if not kernel32.SetInformationJobObject( 

169 job, _JOB_OBJECT_EXTENDED_LIMIT_CLASS, ctypes.byref(info), ctypes.sizeof(info) 

170 ): 

171 raise OSError("SetInformationJobObject failed") 

172 handle = kernel32.OpenProcess(_PROCESS_SET_QUOTA | _PROCESS_TERMINATE, False, pid) 

173 if not handle: 

174 raise OSError("OpenProcess failed") 

175 try: 

176 if not kernel32.AssignProcessToJobObject(job, handle): 

177 raise OSError("AssignProcessToJobObject failed") 

178 finally: 

179 kernel32.CloseHandle(handle) 

180 

181 

182def _watch_via_death_pipe(pid: int) -> None: 

183 """Signal *pid* from a detached ``sh`` watcher once a pipe reaches EOF. 

184 

185 Portable stand-in for prctl/job objects: we hold the pipe's write end until the 

186 child stops or we die, and the watcher (reading the read end as its stdin) 

187 signals *pid* at EOF. The write end is O_CLOEXEC, so exec'd children never 

188 inherit it; a fork-without-exec child would keep the pipe open past our death, 

189 so a bound child's owner must not fork workers (serve is single-process). 

190 

191 The read end is passed as the watcher's stdin rather than redirected with 

192 ``<&N``: dash mis-dups a multi-digit fd there, silently binding the wrong one. 

193 Best-effort; ``kill -0`` skips an already-dead pid, and :func:`release_death_pipe` 

194 keeps the pid-recycle window to the child's own stop. 

195 """ 

196 try: 

197 read_fd, write_fd = os.pipe() 

198 except OSError: 

199 log.info("Could not bind the engine to this process; the next launch reaps it.") 

200 return 

201 script = f"read -r _; kill -0 {pid} 2>/dev/null && kill {pid}" 

202 try: 

203 _spawner.spawn( 

204 ["/bin/sh", "-c", script], 

205 stdin=read_fd, 

206 start_new_session=True, 

207 stdout=subprocess.DEVNULL, 

208 stderr=subprocess.DEVNULL, 

209 ) 

210 except OSError: 

211 os.close(write_fd) 

212 log.info("Could not bind the engine to this process; the next launch reaps it.") 

213 else: 

214 # A crashed-without-release child can leave a stale entry the OS recycles 

215 # this pid into; close it before the overwrite so it never leaks. 

216 release_death_pipe(pid) 

217 _death_pipe_write_fds[pid] = write_fd 

218 finally: 

219 os.close(read_fd) 

220 

221 

222def release_death_pipe(pid: int) -> None: 

223 """Close the death pipe guarding *pid* so its watcher wakes and exits. 

224 

225 Called when the guarded child is stopped while this process lives on (fleet 

226 reload / model switch), instead of leaving the watcher parked until our death. 

227 A no-op for a pid with no death pipe (kernel-bound platforms, or already released). 

228 """ 

229 write_fd = _death_pipe_write_fds.pop(pid, None) 

230 if write_fd is not None: 

231 with contextlib.suppress(OSError): 

232 os.close(write_fd) 

233 

234 

235def spawn_bound_child( 

236 argv: list[str], 

237 *, 

238 bind_lifetime: bool = True, 

239 death_pipe: bool = True, 

240 **popen_kwargs: Any, 

241) -> subprocess.Popen[Any]: 

242 """Spawn *argv*, by default bound to this process's lifetime. 

243 

244 ``bind_lifetime`` is False when the child is meant to outlive this process 

245 (``keep_engine_warm``); the next-launch reap is then the only cleanup. 

246 

247 ``death_pipe`` is False for a short-lived child: the pipe's watcher lives until 

248 we die, so binding a child that outlives neither costs a process and an fd for 

249 nothing. Kernel bindings have no such cost and still apply. 

250 

251 Pass ``start_new_session=True`` to also put the child in its own group; the 

252 binding does not depend on it. 

253 """ 

254 if not bind_lifetime: 

255 return _spawner.spawn(argv, **popen_kwargs) 

256 

257 preexec = _make_pdeathsig_preexec(os.getpid()) 

258 if preexec is not None: 

259 try: 

260 return _spawner.spawn(argv, preexec_fn=preexec, **popen_kwargs) 

261 except OSError: 

262 log.info("Could not set the death signal on the engine; trying the death pipe.") 

263 

264 proc = _spawner.spawn(argv, **popen_kwargs) 

265 if sys.platform == "win32": 

266 try: 

267 _assign_to_kill_on_close_job(proc.pid) 

268 except OSError: 

269 log.info("Could not bind the engine to this process; the next launch reaps it.") 

270 elif death_pipe: 

271 _watch_via_death_pipe(proc.pid) 

272 return proc