Coverage for src/lilbee/core/system.py: 100%
121 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""OS, environment, and platform helpers for lilbee."""
3import os
4import shutil
5import sys
6import threading
7from collections.abc import Iterator
8from contextlib import contextmanager
9from pathlib import Path
11#: Directory name for a project-local lilbee knowledge base (sibling of ``.git/``).
12LOCAL_ROOT_DIRNAME = ".lilbee"
14# Reentrant: a suppressed block can re-enter this (directly or via a native
15# helper wrapping its own stderr) and a plain Lock self-deadlocks. Nesting
16# restores correctly: the inner exit puts back the outer's devnull.
17_STDERR_LOCK = threading.RLock()
20@contextmanager
21def stderr_suppressed() -> Iterator[None]:
22 """Redirect fd 2 to /dev/null for the duration of the block.
24 Silences C-library stderr (native document extractors, GGUF readers) that
25 bypasses Python's logging. Holds a process lock so concurrent fd-2 swaps
26 can't clobber each other's saved descriptor. Wrap the whole native call, not
27 each inner iteration, so the lock doesn't serialize a hot loop.
29 On Windows, MSVC-built native extensions use GetStdHandle rather than the
30 CRT fd 2, so the fd-dup technique has no effect there. The context manager
31 is a no-op on Windows to avoid false suppression expectations.
32 """
33 if sys.platform == "win32": # pragma: no cover - Windows-only passthrough
34 yield
35 return
36 with _STDERR_LOCK:
37 devnull = os.open(os.devnull, os.O_WRONLY)
38 old_stderr = os.dup(2)
39 os.dup2(devnull, 2)
40 try:
41 yield
42 finally:
43 os.dup2(old_stderr, 2)
44 os.close(devnull)
45 os.close(old_stderr)
48def default_data_dir() -> Path:
49 """Return platform-appropriate data directory.
50 - macOS: ~/Library/Application Support/lilbee
51 - Windows: %LOCALAPPDATA%/lilbee
52 - Linux: ~/.local/share/lilbee (XDG_DATA_HOME)
53 """
54 if sys.platform == "darwin":
55 base = Path.home() / "Library" / "Application Support"
56 elif sys.platform == "win32":
57 base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")).expanduser()
58 else:
59 base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
60 return base / "lilbee"
63def default_state_dir() -> Path:
64 """Return platform-appropriate directory for live runtime state.
65 - macOS: ~/Library/Application Support/lilbee
66 - Windows: %LOCALAPPDATA%/lilbee
67 - Linux: ~/.local/state/lilbee (XDG_STATE_HOME)
69 Deliberately not a cache directory. This holds the machine engine slot: the
70 state files recording a running llama-swap's pid and ports, the refcount
71 lock dir, and the build lock. Those records are the only handle any
72 out-of-process stop has on a running fleet, so a cleaner (or macOS evicting
73 ~/Library/Caches under disk pressure) emptying the dir mid-run would orphan
74 a fleet holding VRAM and leave the slot looking free to the next process,
75 which would then build a second fleet on top of it.
76 """
77 if sys.platform == "darwin": # pragma: no cover - platform split
78 base = Path.home() / "Library" / "Application Support"
79 elif sys.platform == "win32": # pragma: no cover - platform split
80 base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")).expanduser()
81 else: # pragma: no cover - platform split
82 base = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
83 return base / "lilbee"
86def default_cache_dir() -> Path:
87 """Return platform-appropriate directory for regenerable caches.
89 - macOS: ~/Library/Caches/lilbee
90 - Windows: %LOCALAPPDATA%/lilbee/cache
91 - Linux: ~/.cache/lilbee (XDG_CACHE_HOME)
93 The counterpart to :func:`default_state_dir`. Everything here is derived data
94 that costs time, not correctness, to lose, so a cleaner -- or macOS evicting
95 ~/Library/Caches under disk pressure -- may empty it freely. Nothing that a
96 stop path needs to find a running process belongs here.
97 """
98 if sys.platform == "darwin": # pragma: no cover - platform split
99 return Path.home() / "Library" / "Caches" / "lilbee"
100 if sys.platform == "win32": # pragma: no cover - platform split
101 base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")).expanduser()
102 return base / "lilbee" / "cache"
103 return ( # pragma: no cover - platform split
104 Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "lilbee"
105 )
108def find_local_root(start: Path | None = None) -> Path | None:
109 """Walk up from start (default: cwd) looking for a ``.lilbee/`` directory."""
110 start = start or Path.cwd()
111 for candidate in (start, *start.parents):
112 marker = candidate / LOCAL_ROOT_DIRNAME
113 if marker.is_dir():
114 return marker
115 return None
118def canonical_data_root(root: Path | str) -> Path:
119 """Resolve a data root to one canonical path.
121 Session file, port file, and write lock all derive from the data root, so
122 two spellings of one directory key two locks. Symlinks, relative paths, a
123 leading ``~``, and macOS ``/var`` vs ``/private/var`` each produce a pair.
124 A root that does not exist yet resolves to where it will be created.
126 Uses ``os.path`` rather than ``Path.expanduser().resolve()``: ``resolve``
127 rebuilds via ``type(self)``, which raises for a ``PosixPath`` that exists
128 on Windows (``Path()`` picks its flavour from ``os.name``, which tests patch).
129 """
130 return Path(os.path.realpath(os.path.expanduser(os.fspath(root))))
133def canonical_models_dir() -> Path:
134 """Return the shared models directory (always in the platform default, never per-project).
135 Multiple lilbee instances share this directory so models are downloaded once.
136 """
137 return default_data_dir() / "models"
140def is_ignored_dir(name: str, ignore_dirs: frozenset[str]) -> bool:
141 """Return True if a directory name should be skipped during traversal."""
142 return name.startswith(".") or name in ignore_dirs or name.endswith(".egg-info")
145_CTX_TIER_FLOOR = 8192
146_CTX_TIER_TABLE: tuple[tuple[int, int], ...] = (
147 # (total_bytes_threshold, target)
148 # The top tier matches AGENT_CHAT_CTX_FLOOR: a 128 GiB host is a server or
149 # pod whose GPUs can back an agent-sized window, and the dynamic picker
150 # still clamps to trained context and device memory where they cannot.
151 (128 * 1024**3, 65536),
152 (64 * 1024**3, 24576),
153 (32 * 1024**3, 16384),
154 (16 * 1024**3, 12288),
155)
158def chat_ctx_target_for_total_bytes(total_bytes: int) -> int:
159 """Pick a chat_n_ctx_target from total host RAM (floor 8192, tiers at 16/32/64/128 GiB)."""
160 if total_bytes <= 0:
161 return _CTX_TIER_FLOOR
162 for threshold, target in _CTX_TIER_TABLE:
163 if total_bytes >= threshold:
164 return target
165 return _CTX_TIER_FLOOR
168_CGROUP_ROOT = Path("/sys/fs/cgroup")
171def cgroup_memory_limit() -> int | None:
172 """Bytes this process's cgroup allows, or ``None`` when unlimited or unreadable.
174 cgroup v2 keeps the cap in ``memory.max`` (``max`` for unlimited); v1 uses
175 ``memory/memory.limit_in_bytes``, which spells unlimited as a near-int64
176 sentinel rather than a word, and so reads as a limit above installed RAM.
177 Both are read, matching the CPU quota reader in :mod:`lilbee.runtime.cpu`.
179 Every reader of host memory needs this: psutil reports the machine's
180 ``/proc/meminfo``, which a memory-capped container sees in full, so a 4 GiB
181 container on a 512 GiB machine sizes itself for the machine and is killed by
182 the OOM reaper on its first load.
183 """
184 for path in (_CGROUP_ROOT / "memory.max", _CGROUP_ROOT / "memory" / "memory.limit_in_bytes"):
185 try:
186 raw = path.read_text(encoding="utf-8").strip()
187 except OSError:
188 continue
189 if raw == "max":
190 return None
191 try:
192 return int(raw)
193 except ValueError:
194 return None
195 return None
198def cgroup_memory_used() -> int | None:
199 """Bytes this process's cgroup currently holds, or ``None`` when unreadable."""
200 for path in (
201 _CGROUP_ROOT / "memory.current",
202 _CGROUP_ROOT / "memory" / "memory.usage_in_bytes",
203 ):
204 try:
205 return int(path.read_text(encoding="utf-8").strip())
206 except (OSError, ValueError):
207 continue
208 return None
211def capped_total_memory() -> int:
212 """Total RAM this process may use in bytes; raises if the host cannot be read.
214 Bounded by the cgroup cap where one applies; a limit above installed RAM is
215 no limit at all, which is also how cgroup v1 spells unlimited.
216 """
217 import psutil
219 host_total = int(psutil.virtual_memory().total)
220 limit = cgroup_memory_limit()
221 return min(host_total, limit) if limit is not None else host_total
224def _read_total_memory_bytes() -> int:
225 """:func:`capped_total_memory`, or 0 when introspection is unavailable.
227 The config default needs an answer at import time and has a floor to fall
228 back to, so it swallows the failure. Callers sizing a real placement want the
229 exception instead: a budget silently computed from zero refuses every model
230 with no reason given.
231 """
232 try:
233 return capped_total_memory()
234 except Exception:
235 # psutil import or platform read failed; the caller falls back to the floor.
236 return 0
239def scaled_chat_ctx_target_default() -> int:
240 """Pick a chat_n_ctx_target from this host's total RAM at config-load time."""
241 return chat_ctx_target_for_total_bytes(_read_total_memory_bytes())
244# Filesystem types whose backing store is a network, where mmap page faults are
245# served over the wire and can wedge the model loader in uninterruptible I/O. The
246# exact type string a given volume reports (e.g. a RunPod network volume) is
247# confirmed on the target host and added here.
248_NETWORK_FS_TYPES = frozenset(
249 {"nfs", "nfs4", "cifs", "smb3", "smbfs", "9p", "ceph", "glusterfs", "lustre", "beegfs", "afs"}
250)
251# A /proc/mounts line is "device mountpoint fstype options ...": at least 3 fields.
252_PROC_MOUNTS_MIN_FIELDS = 3
253_PROC_MOUNTS = Path("/proc/mounts")
256def _mount_fstype(path: str, mounts_text: str) -> str:
257 """Filesystem type of the longest mount point in *mounts_text* that covers *path*."""
258 best_mount = ""
259 best_type = ""
260 for line in mounts_text.splitlines():
261 parts = line.split()
262 if len(parts) < _PROC_MOUNTS_MIN_FIELDS:
263 continue
264 mount_point, fs_type = parts[1], parts[2]
265 covers = path == mount_point or path.startswith(mount_point.rstrip("/") + "/")
266 if covers and len(mount_point) >= len(best_mount):
267 best_mount, best_type = mount_point, fs_type
268 return best_type
271def is_network_path(path: Path) -> bool:
272 """Whether *path* lives on a network filesystem.
274 mmap over a network filesystem faults pages over the wire, which can stall a
275 large-model load in uninterruptible I/O. Linux-only (reads ``/proc/mounts``);
276 returns False on other platforms and on any read failure, so local disk is the
277 safe assumption.
278 """
279 try:
280 mounts_text = _PROC_MOUNTS.read_text(encoding="utf-8")
281 except OSError:
282 return False
283 try:
284 resolved = str(path.resolve())
285 except OSError:
286 resolved = str(path)
287 fstype = _mount_fstype(resolved, mounts_text)
288 return fstype in _NETWORK_FS_TYPES or fstype.startswith("fuse.")
291_EXTRA_BIN_DIRS: tuple[str, ...] = ("~/.local/bin", "~/.bun/bin")
292_UNIX_BIN_DIRS: tuple[str, ...] = ("/opt/homebrew/bin", "/usr/local/bin")
293_WINDOWS_BIN_DIRS: tuple[str, ...] = ("~/AppData/Roaming/npm", "~/AppData/Local/Programs")
296def executable_search_path() -> str:
297 """PATH plus the directories user-level installers put executables in.
299 A server started from a desktop session inherits the login PATH, which
300 misses the package-manager and per-user install dirs a shell profile adds.
301 """
302 platform_dirs = _WINDOWS_BIN_DIRS if sys.platform == "win32" else _UNIX_BIN_DIRS
303 entries = [os.environ.get("PATH", "")]
304 entries += [str(Path(d).expanduser()) for d in (*_EXTRA_BIN_DIRS, *platform_dirs)]
305 return os.pathsep.join(entry for entry in entries if entry)
308def find_executable(name: str) -> str | None:
309 """Absolute path to the *name* executable, or None when it is not installed."""
310 return shutil.which(name, path=executable_search_path())