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

1"""OS, environment, and platform helpers for lilbee.""" 

2 

3import os 

4import shutil 

5import sys 

6import threading 

7from collections.abc import Iterator 

8from contextlib import contextmanager 

9from pathlib import Path 

10 

11#: Directory name for a project-local lilbee knowledge base (sibling of ``.git/``). 

12LOCAL_ROOT_DIRNAME = ".lilbee" 

13 

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

18 

19 

20@contextmanager 

21def stderr_suppressed() -> Iterator[None]: 

22 """Redirect fd 2 to /dev/null for the duration of the block. 

23 

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. 

28 

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) 

46 

47 

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" 

61 

62 

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) 

68 

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" 

84 

85 

86def default_cache_dir() -> Path: 

87 """Return platform-appropriate directory for regenerable caches. 

88 

89 - macOS: ~/Library/Caches/lilbee 

90 - Windows: %LOCALAPPDATA%/lilbee/cache 

91 - Linux: ~/.cache/lilbee (XDG_CACHE_HOME) 

92 

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 ) 

106 

107 

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 

116 

117 

118def canonical_data_root(root: Path | str) -> Path: 

119 """Resolve a data root to one canonical path. 

120 

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. 

125 

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

131 

132 

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" 

138 

139 

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

143 

144 

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) 

156 

157 

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 

166 

167 

168_CGROUP_ROOT = Path("/sys/fs/cgroup") 

169 

170 

171def cgroup_memory_limit() -> int | None: 

172 """Bytes this process's cgroup allows, or ``None`` when unlimited or unreadable. 

173 

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`. 

178 

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 

196 

197 

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 

209 

210 

211def capped_total_memory() -> int: 

212 """Total RAM this process may use in bytes; raises if the host cannot be read. 

213 

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 

218 

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 

222 

223 

224def _read_total_memory_bytes() -> int: 

225 """:func:`capped_total_memory`, or 0 when introspection is unavailable. 

226 

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 

237 

238 

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

242 

243 

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

254 

255 

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 

269 

270 

271def is_network_path(path: Path) -> bool: 

272 """Whether *path* lives on a network filesystem. 

273 

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.") 

289 

290 

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

294 

295 

296def executable_search_path() -> str: 

297 """PATH plus the directories user-level installers put executables in. 

298 

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) 

306 

307 

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