Coverage for src/lilbee/core/system.py: 100%
138 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +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":
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 _dir_from_env(key: str, *home_relative: str) -> Path:
49 """Path named by environment variable *key*, or *home_relative* under the home directory.
51 The home directory is resolved only when the variable is unset, because
52 ``Path.home()`` raises where no home directory resolves.
53 """
54 value = os.environ.get(key)
55 return Path(value) if value is not None else Path.home().joinpath(*home_relative)
58def default_data_dir() -> Path:
59 """Return platform-appropriate data directory.
60 - macOS: ~/Library/Application Support/lilbee
61 - Windows: %LOCALAPPDATA%/lilbee
62 - Linux: ~/.local/share/lilbee (XDG_DATA_HOME)
63 """
64 if sys.platform == "darwin":
65 base = Path.home() / "Library" / "Application Support"
66 elif sys.platform == "win32":
67 base = _dir_from_env("LOCALAPPDATA", "AppData", "Local").expanduser()
68 else:
69 base = _dir_from_env("XDG_DATA_HOME", ".local", "share")
70 return base / "lilbee"
73def default_state_dir() -> Path:
74 """Return platform-appropriate directory for live runtime state.
75 - macOS: ~/Library/Application Support/lilbee
76 - Windows: %LOCALAPPDATA%/lilbee
77 - Linux: ~/.local/state/lilbee (XDG_STATE_HOME)
79 Deliberately not a cache directory. This holds the machine engine slot: the
80 state files recording a running llama-swap's pid and ports, the refcount
81 lock dir, and the build lock. Those records are the only handle any
82 out-of-process stop has on a running fleet, so a cleaner (or macOS evicting
83 ~/Library/Caches under disk pressure) emptying the dir mid-run would orphan
84 a fleet holding VRAM and leave the slot looking free to the next process,
85 which would then build a second fleet on top of it.
86 """
87 if sys.platform == "darwin":
88 base = Path.home() / "Library" / "Application Support"
89 elif sys.platform == "win32":
90 base = _dir_from_env("LOCALAPPDATA", "AppData", "Local").expanduser()
91 else:
92 base = _dir_from_env("XDG_STATE_HOME", ".local", "state")
93 return base / "lilbee"
96def default_cache_dir() -> Path:
97 """Return platform-appropriate directory for regenerable caches.
99 - macOS: ~/Library/Caches/lilbee
100 - Windows: %LOCALAPPDATA%/lilbee/cache
101 - Linux: ~/.cache/lilbee (XDG_CACHE_HOME)
103 The counterpart to :func:`default_state_dir`. Everything here is derived data
104 that costs time, not correctness, to lose, so a cleaner -- or macOS evicting
105 ~/Library/Caches under disk pressure -- may empty it freely. Nothing that a
106 stop path needs to find a running process belongs here.
107 """
108 if sys.platform == "darwin":
109 return Path.home() / "Library" / "Caches" / "lilbee"
110 if sys.platform == "win32":
111 base = _dir_from_env("LOCALAPPDATA", "AppData", "Local").expanduser()
112 return base / "lilbee" / "cache"
113 return _dir_from_env("XDG_CACHE_HOME", ".cache") / "lilbee"
116def find_local_root(start: Path | None = None) -> Path | None:
117 """Walk up from start (default: cwd) looking for a ``.lilbee/`` directory."""
118 start = start or Path.cwd()
119 for candidate in (start, *start.parents):
120 marker = candidate / LOCAL_ROOT_DIRNAME
121 if marker.is_dir():
122 return marker
123 return None
126def canonical_data_root(root: Path | str) -> Path:
127 """Resolve a data root to one canonical path.
129 Session file, port file, and write lock all derive from the data root, so
130 two spellings of one directory key two locks. Symlinks, relative paths, a
131 leading ``~``, and macOS ``/var`` vs ``/private/var`` each produce a pair.
132 A root that does not exist yet resolves to where it will be created.
134 Uses ``os.path`` rather than ``Path.expanduser().resolve()``: ``resolve``
135 rebuilds via ``type(self)``, which raises for a ``PosixPath`` that exists
136 on Windows (``Path()`` picks its flavour from ``os.name``, which tests patch).
137 """
138 return Path(os.path.realpath(os.path.expanduser(os.fspath(root))))
141def canonical_models_dir() -> Path:
142 """Return the shared models directory (always in the platform default, never per-project).
143 Multiple lilbee instances share this directory so models are downloaded once.
144 """
145 return default_data_dir() / "models"
148def is_ignored_dir(name: str, ignore_dirs: frozenset[str]) -> bool:
149 """Return True if a directory name should be skipped during traversal."""
150 return name.startswith(".") or name in ignore_dirs or name.endswith(".egg-info")
153_CTX_TIER_FLOOR = 8192
154_CTX_TIER_TABLE: tuple[tuple[int, int], ...] = (
155 # (total_bytes_threshold, target)
156 # The top tier matches AGENT_CHAT_CTX_FLOOR: a 128 GiB host is a server or
157 # pod whose GPUs can back an agent-sized window, and the dynamic picker
158 # still clamps to trained context and device memory where they cannot.
159 (128 * 1024**3, 65536),
160 (64 * 1024**3, 24576),
161 (32 * 1024**3, 16384),
162 (16 * 1024**3, 12288),
163)
166def chat_ctx_target_for_total_bytes(total_bytes: int) -> int:
167 """Pick a chat_n_ctx_target from total host RAM (floor 8192, tiers at 16/32/64/128 GiB)."""
168 if total_bytes <= 0:
169 return _CTX_TIER_FLOOR
170 for threshold, target in _CTX_TIER_TABLE:
171 if total_bytes >= threshold:
172 return target
173 return _CTX_TIER_FLOOR
176_CGROUP_ROOT = Path("/sys/fs/cgroup")
179def cgroup_memory_limit() -> int | None:
180 """Bytes this process's cgroup allows, or ``None`` when unlimited or unreadable.
182 cgroup v2 keeps the cap in ``memory.max`` (``max`` for unlimited); v1 uses
183 ``memory/memory.limit_in_bytes``, which spells unlimited as a near-int64
184 sentinel rather than a word, and so reads as a limit above installed RAM.
185 Both are read, matching the CPU quota reader in :mod:`lilbee.runtime.cpu`.
187 Every reader of host memory needs this: psutil reports the machine's
188 ``/proc/meminfo``, which a memory-capped container sees in full, so a 4 GiB
189 container on a 512 GiB machine sizes itself for the machine and is killed by
190 the OOM reaper on its first load.
191 """
192 for path in (_CGROUP_ROOT / "memory.max", _CGROUP_ROOT / "memory" / "memory.limit_in_bytes"):
193 try:
194 raw = path.read_text(encoding="utf-8").strip()
195 except OSError:
196 continue
197 if raw == "max":
198 return None
199 try:
200 return int(raw)
201 except ValueError:
202 return None
203 return None
206def cgroup_memory_used() -> int | None:
207 """Bytes this process's cgroup currently holds, or ``None`` when unreadable."""
208 for path in (
209 _CGROUP_ROOT / "memory.current",
210 _CGROUP_ROOT / "memory" / "memory.usage_in_bytes",
211 ):
212 try:
213 return int(path.read_text(encoding="utf-8").strip())
214 except (OSError, ValueError):
215 continue
216 return None
219def capped_total_memory() -> int:
220 """Total RAM this process may use in bytes; raises if the host cannot be read.
222 Bounded by the cgroup cap where one applies; a limit above installed RAM is
223 no limit at all, which is also how cgroup v1 spells unlimited.
224 """
225 import psutil
227 host_total = int(psutil.virtual_memory().total)
228 limit = cgroup_memory_limit()
229 return min(host_total, limit) if limit is not None else host_total
232def _read_total_memory_bytes() -> int:
233 """:func:`capped_total_memory`, or 0 when introspection is unavailable.
235 The config default needs an answer at import time and has a floor to fall
236 back to, so it swallows the failure. Callers sizing a real placement want the
237 exception instead: a budget silently computed from zero refuses every model
238 with no reason given.
239 """
240 try:
241 return capped_total_memory()
242 except Exception:
243 # psutil import or platform read failed; the caller falls back to the floor.
244 return 0
247def scaled_chat_ctx_target_default() -> int:
248 """Pick a chat_n_ctx_target from this host's total RAM at config-load time."""
249 return chat_ctx_target_for_total_bytes(_read_total_memory_bytes())
252# Filesystem types whose backing store is a network, where mmap page faults are
253# served over the wire and can wedge the model loader in uninterruptible I/O. The
254# exact type string a given volume reports (e.g. a RunPod network volume) is
255# confirmed on the target host and added here.
256_NETWORK_FS_TYPES = frozenset(
257 {"nfs", "nfs4", "cifs", "smb3", "smbfs", "9p", "ceph", "glusterfs", "lustre", "beegfs", "afs"}
258)
259# A /proc/mounts line is "device mountpoint fstype options ...": at least 3 fields.
260_PROC_MOUNTS_MIN_FIELDS = 3
261_PROC_MOUNTS = Path("/proc/mounts")
264def _mount_fstype(path: str, mounts_text: str) -> str:
265 """Filesystem type of the longest mount point in *mounts_text* that covers *path*."""
266 best_mount = ""
267 best_type = ""
268 for line in mounts_text.splitlines():
269 parts = line.split()
270 if len(parts) < _PROC_MOUNTS_MIN_FIELDS:
271 continue
272 mount_point, fs_type = parts[1], parts[2]
273 covers = path == mount_point or path.startswith(mount_point.rstrip("/") + "/")
274 if covers and len(mount_point) >= len(best_mount):
275 best_mount, best_type = mount_point, fs_type
276 return best_type
279def is_network_path(path: Path) -> bool:
280 """Whether *path* lives on a network filesystem.
282 mmap over a network filesystem faults pages over the wire, which can stall a
283 large-model load in uninterruptible I/O. Linux-only (reads ``/proc/mounts``);
284 returns False on other platforms and on any read failure, so local disk is the
285 safe assumption.
286 """
287 try:
288 mounts_text = _PROC_MOUNTS.read_text(encoding="utf-8")
289 except OSError:
290 return False
291 try:
292 resolved = str(path.resolve())
293 except OSError:
294 resolved = str(path)
295 fstype = _mount_fstype(resolved, mounts_text)
296 return fstype in _NETWORK_FS_TYPES or fstype.startswith("fuse.")
299_EXTRA_BIN_DIRS: tuple[str, ...] = ("~/.local/bin", "~/.bun/bin")
300_UNIX_BIN_DIRS: tuple[str, ...] = ("/opt/homebrew/bin", "/usr/local/bin")
301_WINDOWS_BIN_DIRS: tuple[str, ...] = ("~/AppData/Roaming/npm", "~/AppData/Local/Programs")
304def executable_search_path() -> str:
305 """PATH plus the directories user-level installers put executables in.
307 A server started from a desktop session inherits the login PATH, which
308 misses the package-manager and per-user install dirs a shell profile adds.
309 """
310 platform_dirs = _WINDOWS_BIN_DIRS if sys.platform == "win32" else _UNIX_BIN_DIRS
311 entries = [os.environ.get("PATH", "")]
312 entries += [str(Path(d).expanduser()) for d in (*_EXTRA_BIN_DIRS, *platform_dirs)]
313 return os.pathsep.join(entry for entry in entries if entry)
316def find_executable(name: str) -> str | None:
317 """Absolute path to the *name* executable, or None when it is not installed."""
318 return shutil.which(name, path=executable_search_path())