Coverage for src/lilbee/runtime/engine_lock.py: 100%
150 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Cross-process lifecycle primitives for the shared engine.
3The engine (llama-swap + llama-server processes) is machine-level infrastructure:
4any lilbee process may build it, every compatible process binds to it, and the
5kernel arbitrates liveness through file locks so no pid bookkeeping can go stale.
6The mechanics are agnostic to what they front.
7"""
9from __future__ import annotations
11import hashlib
12import logging
13import math
14import os
15from contextlib import contextmanager, suppress
16from dataclasses import dataclass, field
17from functools import lru_cache
18from pathlib import Path
19from typing import TYPE_CHECKING
21from filelock import FileLock, SoftFileLock
22from filelock import Timeout as FileLockTimeout
24from lilbee.runtime.shard_process import shard_process
26if TYPE_CHECKING:
27 from collections.abc import Iterator
29log = logging.getLogger(__name__)
31ENGINE_DIR_ENV = "LILBEE_ENGINE_DIR"
32_BUILD_LOCK_NAME = "engine.lock"
33# A legitimate build holds the lock for the llama-swap spawn (a 30 s boot budget)
34# and, before it, the planning sweep, which shells out to gguf-parser once per
35# split candidate and per context probe. The model itself loads lazily on the
36# first request, outside the lock. So honest contention can run well past the
37# spawn budget on a wide box, and the bound is here to catch a wedged holder
38# rather than to block forever, which would deadlock every startup and exit.
39_BUILD_LOCK_TIMEOUT_S = 90.0
40# What a caller that will proceed without the lock waits for it. Teardown and
41# config-change are best-effort by definition, so spending the build timeout
42# before giving up buys nothing and costs everything: an init system's TERM
43# window is typically 90 seconds, and a shutdown that waits that long to then
44# proceed anyway gets SIGKILLed mid-teardown, leaving the engines it was about
45# to stop alive and holding VRAM.
46_BEST_EFFORT_LOCK_TIMEOUT_S = 5.0
47_USERS_DIRNAME = "engine-users"
48# Held for its whole life by the one reaper process watching a dir's engine.
49_REAPER_LOCK_NAME = "engine-reaper.lock"
50_USER_LOCK_SUFFIX = ".lock"
51# One opt-in file per installation, keyed by config root (not pid, which changes
52# every run). Plain files, not locks: an opt-in outlives its process. Per-install
53# so a restart reclaims its own prior mark while a peer's stays distinct.
54_KEEP_WARM_SUFFIX = ".keep-warm"
55_SECONDS_PER_MINUTE = 60
56# Throwaway per-process file used to ask whether flock really works here.
57_FLOCK_PROBE_PREFIX = ".flock-probe."
58# Non-blocking probe: a live peer refuses instantly.
59_PROBE_TIMEOUT_S = 0.0
60# Finite: infinite acquires trip filelock's thread-local deadlock detection
61# after a cross-thread release. The pid-named file has no live contender, so
62# this never waits in practice.
63_HOLD_TIMEOUT_S = 10.0
66def machine_engine_dir() -> Path:
67 """The per-OS-user engine slot every lilbee process scans first."""
68 from lilbee.core.system import default_state_dir
70 if shard_process.engine_dir is not None:
71 return shard_process.engine_dir
72 override = os.environ.get(ENGINE_DIR_ENV, "").strip()
73 if override:
74 return Path(override)
75 return default_state_dir() / "engine"
78def private_engine_dir(config_root: Path) -> Path:
79 """The overflow engine dir for one config root, used when the slot is incompatible."""
80 return config_root / "data" / "engine"
83@contextmanager
84def build_lock(engine_dir: Path, *, best_effort: bool = False) -> Iterator[None]:
85 """Serialize scan-or-build (and config-change restarts) for *engine_dir*.
87 Acquired with a finite timeout so a wedged holder cannot deadlock the machine:
88 a blocking acquire would hang every startup behind it and, on the shutdown and
89 config-change paths, leave processes unable to exit. A wait is logged so a
90 stall is visible. On timeout a build caller raises (it could not acquire the
91 engine, better than an unbounded hang); a *best_effort* caller -- teardown and
92 config-change, which must not wedge a dying or reconfiguring process -- logs
93 and proceeds without the lock.
94 """
95 engine_dir.mkdir(parents=True, exist_ok=True)
96 lock = FileLock(engine_dir / _BUILD_LOCK_NAME)
97 wait_s = _BEST_EFFORT_LOCK_TIMEOUT_S if best_effort else _BUILD_LOCK_TIMEOUT_S
98 try:
99 lock.acquire(timeout=_PROBE_TIMEOUT_S)
100 except FileLockTimeout:
101 log.info(
102 "Waiting up to %.0fs for another process to build the engine at %s",
103 wait_s,
104 engine_dir,
105 )
106 try:
107 lock.acquire(timeout=wait_s)
108 except FileLockTimeout:
109 if not best_effort:
110 raise
111 log.warning(
112 "Engine build lock at %s held past %.0fs; proceeding without it.",
113 engine_dir,
114 wait_s,
115 )
116 yield
117 return
118 try:
119 yield
120 finally:
121 lock.release()
124@lru_cache(maxsize=8)
125def kernel_arbitrates_locks(engine_dir: Path) -> bool:
126 """Whether *engine_dir*'s filesystem gives real kernel-arbitrated file locks.
128 The whole membership scheme rests on the kernel releasing a lock on any
129 death, so no pid bookkeeping can go stale. On a filesystem where flock
130 returns ENOSYS (FUSE, some NFS mounts) filelock silently rewrites itself to
131 SoftFileLock with only a Python warning, and the guarantee is gone: that
132 fallback path opens the lock file with O_TRUNC and unlinks it before
133 re-acquiring, so a process merely *probing* a live member's lock destroys
134 it. live_users_exist would then report an empty slot while members are
135 serving, and the last-out stop would kill an engine in use.
137 Probed by acquiring a throwaway lock in the dir and checking what filelock
138 turned it into; cached, since the answer is a property of the mount.
139 """
140 engine_dir.mkdir(parents=True, exist_ok=True)
141 # Named per process: the probe asks what the filesystem supports, which needs
142 # no mutual exclusion. A shared probe file would make every lilbee queue on
143 # one lock, and this runs while the in-process build lock is held.
144 probe_path = engine_dir / f"{_FLOCK_PROBE_PREFIX}{os.getpid()}"
145 lock = FileLock(probe_path, thread_local=False)
146 try:
147 lock.acquire(timeout=_HOLD_TIMEOUT_S)
148 except (FileLockTimeout, OSError):
149 # An unusable probe file says nothing about flock support; assume the
150 # filesystem is fine rather than refuse the shared slot over it.
151 return True
152 try:
153 return not isinstance(lock, SoftFileLock)
154 finally:
155 lock.release()
156 probe_path.unlink(missing_ok=True)
159def _users_dir(engine_dir: Path) -> Path:
160 return engine_dir / _USERS_DIRNAME
163def _keep_warm_path(engine_dir: Path, config_root: Path) -> Path:
164 token = hashlib.blake2b(str(config_root).encode(), digest_size=8).hexdigest()
165 return _users_dir(engine_dir) / f"{token}{_KEEP_WARM_SUFFIX}"
168def request_keep_warm(engine_dir: Path, config_root: Path, idle_minutes: int) -> None:
169 """Record that the installation at *config_root* wants *engine_dir*'s engine warm.
171 The slot is shared across installations whose configs differ, so any one's
172 opt-in keeps the engine warm even when a default-config sibling is last out.
173 Keyed by config root, not pid: a restart of the same installation reclaims
174 its own mark. The marker holds *idle_minutes*, how long the weights stay
175 loaded after the last user leaves. ``stop_engine`` clears the set, so a
176 rebuilt engine starts unmarked.
177 """
178 marker = _keep_warm_path(engine_dir, config_root)
179 marker.parent.mkdir(parents=True, exist_ok=True)
180 marker.write_text(str(idle_minutes), encoding="utf-8")
183def withdraw_keep_warm(engine_dir: Path, config_root: Path) -> None:
184 """Drop this installation's opt-in for *engine_dir*, leaving every peer's intact.
186 Reclaims the mark this installation left, so flipping the setting off (even
187 across a restart) lets the engine stop instead of staying warm forever.
188 """
189 _keep_warm_path(engine_dir, config_root).unlink(missing_ok=True)
192def keep_warm_requested(engine_dir: Path) -> bool:
193 """Whether any user of *engine_dir* asked for the engine to stay resident.
195 Not gated on liveness: an opt-in means "outlive me", so an exited user's
196 marker is the case it exists for.
197 """
198 return bool(_keep_warm_markers(engine_dir))
201def keep_warm_idle_s(engine_dir: Path, *, unset_minutes: int) -> float:
202 """Seconds a kept-warm engine's weights stay loaded with no user; the longest opt-in wins.
204 A marker of 0 never unloads (infinite). An empty or unreadable marker counts
205 as *unset_minutes*.
206 """
207 minutes = [_marker_minutes(path, unset_minutes) for path in _keep_warm_markers(engine_dir)]
208 longest = max(minutes, default=unset_minutes)
209 return math.inf if 0 in minutes else longest * _SECONDS_PER_MINUTE
212def _keep_warm_markers(engine_dir: Path) -> list[Path]:
213 return list(_users_dir(engine_dir).glob(f"*{_KEEP_WARM_SUFFIX}"))
216def _marker_minutes(marker: Path, unset_minutes: int) -> int:
217 try:
218 return int(marker.read_text(encoding="utf-8"))
219 except (OSError, ValueError):
220 return unset_minutes
223def clear_keep_warm(engine_dir: Path) -> None:
224 """Forget every persistence opt-in for *engine_dir*; the engine they applied to is gone."""
225 for marker in _keep_warm_markers(engine_dir):
226 marker.unlink(missing_ok=True)
229def _unlink_best_effort(path: Path) -> None:
230 """Delete a released user lock file; a leftover is harmless.
232 Windows refuses to delete a file another handle has open (WinError 32),
233 for example a concurrent probe. The next probe deletes the leftover.
234 """
235 with suppress(OSError):
236 path.unlink(missing_ok=True)
239def _user_lock_path(engine_dir: Path, pid: int) -> Path:
240 return _users_dir(engine_dir) / f"{pid}{_USER_LOCK_SUFFIX}"
243@dataclass
244class UserLockHold:
245 """One process's held membership in an engine's user set."""
247 engine_dir: Path
248 path: Path
249 _lock: FileLock = field(repr=False)
251 def release_and_check_last(self) -> bool:
252 """Release this hold and report whether no live peers remain.
254 Idempotent. The lock file is removed when the last in-process hold
255 releases; acquirable peer files belong to dead processes and are
256 deleted in passing.
257 """
258 if self._lock.is_locked:
259 self._lock.release()
260 if not self._lock.is_locked:
261 _unlink_best_effort(self.path)
262 return not live_users_exist(self.engine_dir)
265def hold_user_lock(engine_dir: Path, pid: int | None = None) -> UserLockHold:
266 """Hold this process's user lock for *engine_dir* until released or death.
268 *pid* names the lock file (defaults to this process); tests pass explicit
269 pids to simulate peers from one process.
270 """
271 path = _user_lock_path(engine_dir, os.getpid() if pid is None else pid)
272 path.parent.mkdir(parents=True, exist_ok=True)
273 lock = _user_file_lock(path)
274 lock.acquire(timeout=_HOLD_TIMEOUT_S)
275 return UserLockHold(engine_dir=engine_dir, path=path, _lock=lock)
278def _user_file_lock(path: Path) -> FileLock:
279 """One process-wide reentrant lock instance per user-lock path.
281 Not thread-local: acquire and release run on different threads.
282 Singleton: two providers in one process hold the same pid-named file,
283 and separate instances over fcntl falsely succeed or trip filelock's
284 deadlock detection.
285 """
286 return FileLock(path, thread_local=False, is_singleton=True)
289def live_users_exist(engine_dir: Path, *, exclude_self: bool = False) -> bool:
290 """Probe every user lock file; clean the dead, report whether any refused.
292 *exclude_self* skips this process's own lock file, so the answer is whether
293 another process uses the engine.
294 """
295 users = _users_dir(engine_dir)
296 own = _user_lock_path(engine_dir, os.getpid()) if exclude_self else None
297 for path in sorted(users.glob(f"*{_USER_LOCK_SUFFIX}")):
298 if path == own:
299 continue
300 probe = _user_file_lock(path)
301 if probe.is_locked:
302 # Held by this process; acquiring would reentrantly succeed.
303 return True
304 try:
305 probe.acquire(timeout=_PROBE_TIMEOUT_S)
306 except FileLockTimeout:
307 return True
308 probe.release()
309 _unlink_best_effort(path)
310 return False
313def try_hold_reaper_lock(engine_dir: Path, *, timeout: float = _PROBE_TIMEOUT_S) -> FileLock | None:
314 """Hold *engine_dir*'s reaper lock, or return None when it stays held for *timeout*."""
315 engine_dir.mkdir(parents=True, exist_ok=True)
316 lock = FileLock(engine_dir / _REAPER_LOCK_NAME, thread_local=False)
317 try:
318 lock.acquire(timeout=timeout)
319 except FileLockTimeout:
320 return None
321 return lock
324def reaper_running(engine_dir: Path) -> bool:
325 """Whether a live reaper process watches *engine_dir*'s engine."""
326 lock = try_hold_reaper_lock(engine_dir)
327 if lock is None:
328 return True
329 lock.release()
330 return False