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

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

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 _dir_from_env(key: str, *home_relative: str) -> Path: 

49 """Path named by environment variable *key*, or *home_relative* under the home directory. 

50 

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) 

56 

57 

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" 

71 

72 

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) 

78 

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" 

94 

95 

96def default_cache_dir() -> Path: 

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

98 

99 - macOS: ~/Library/Caches/lilbee 

100 - Windows: %LOCALAPPDATA%/lilbee/cache 

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

102 

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" 

114 

115 

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 

124 

125 

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

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

128 

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. 

133 

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

139 

140 

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" 

146 

147 

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

151 

152 

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) 

164 

165 

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 

174 

175 

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

177 

178 

179def cgroup_memory_limit() -> int | None: 

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

181 

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

186 

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 

204 

205 

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 

217 

218 

219def capped_total_memory() -> int: 

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

221 

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 

226 

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 

230 

231 

232def _read_total_memory_bytes() -> int: 

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

234 

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 

245 

246 

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

250 

251 

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

262 

263 

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 

277 

278 

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

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

281 

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

297 

298 

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

302 

303 

304def executable_search_path() -> str: 

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

306 

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) 

314 

315 

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