# Conflicts: # .github/workflows/docker.yml # Dockerfile # apps/desktop/src/app/settings/about-settings.tsx # docker/stage2-hook.sh
134 lines
5.6 KiB
Python
134 lines
5.6 KiB
Python
"""Host resources the screen needs before it starts: memory.
|
|
|
|
A headed desktop is a big allocation relative to a small gateway host: Xvnc + Xfce idle at ~220 MiB
|
|
and the browser a human opens during a takeover adds 500 MiB to 1 GiB (measured in the official image:
|
|
gateway 304 MiB → +Xfce 520 MiB → +Chromium on one page 1,073 MiB). On a 4 GB container the loser
|
|
of that squeeze is Chromium, killed mid-login, or the gateway itself. So ``runtime.start()`` refuses
|
|
while the host cannot spare ``bot_desktop.min_free_memory_mb`` and ``display.status`` says why, which
|
|
the pane shows in place of the Start button.
|
|
|
|
"Available" is the tighter of two numbers: what the cgroup still allows (a container limit; v2
|
|
``memory.max``/``memory.current``, v1 ``memory.limit_in_bytes``/``memory.usage_in_bytes``) and what
|
|
the kernel reports free for the whole machine (``MemAvailable``) — a limit of 8 GB on a 4 GB host is
|
|
not 8 GB.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
_CGROUP_V2 = Path("/sys/fs/cgroup")
|
|
_CGROUP_V1 = Path("/sys/fs/cgroup/memory")
|
|
_MEMINFO = Path("/proc/meminfo")
|
|
_MIB = 1024 * 1024
|
|
DEFAULT_MIN_FREE_MB = 1536
|
|
|
|
|
|
@dataclass
|
|
class MemoryInfo:
|
|
available_mb: Optional[int] # None when nothing readable (non-Linux, odd sandboxes)
|
|
limit_mb: Optional[int] # the cgroup limit when there is one, else the machine's MemTotal
|
|
|
|
|
|
def _read_int(path: Path) -> Optional[int]:
|
|
try:
|
|
text = path.read_text(encoding="utf-8-sig").strip()
|
|
except OSError:
|
|
return None
|
|
return int(text) if text.isdigit() else None # "max" → None
|
|
|
|
|
|
def _meminfo() -> dict[str, int]:
|
|
try:
|
|
lines = _MEMINFO.read_text(encoding="utf-8-sig").splitlines()
|
|
except OSError:
|
|
return {}
|
|
out: dict[str, int] = {}
|
|
for line in lines:
|
|
key, _, rest = line.partition(":")
|
|
parts = rest.split()
|
|
if parts and parts[0].isdigit():
|
|
out[key] = int(parts[0]) * 1024 # kB
|
|
return out
|
|
|
|
|
|
def _stat_value(path: Path, key: str) -> Optional[int]:
|
|
"""One ``<key> <bytes>`` line out of a cgroup ``memory.stat``."""
|
|
try:
|
|
for line in path.read_text(encoding="utf-8-sig").splitlines():
|
|
name, _, rest = line.partition(" ")
|
|
if name == key:
|
|
return int(rest.strip())
|
|
except (OSError, ValueError):
|
|
return None
|
|
return None
|
|
|
|
|
|
def _cgroup_limit_and_usage() -> tuple[Optional[int], Optional[int]]:
|
|
"""The cgroup's limit and its *working set* — usage minus reclaimable page cache.
|
|
|
|
``memory.current`` counts page cache, so a container reads several hundred MB above idle right after a
|
|
desktop stops with every process gone. Charging that would make the gate tighten over uptime, so we
|
|
subtract ``inactive_file``, the working-set convention kubelet uses.
|
|
"""
|
|
limit = _read_int(_CGROUP_V2 / "memory.max")
|
|
usage = _read_int(_CGROUP_V2 / "memory.current")
|
|
cache = _stat_value(_CGROUP_V2 / "memory.stat", "inactive_file")
|
|
if limit is None and usage is None:
|
|
limit = _read_int(_CGROUP_V1 / "memory.limit_in_bytes")
|
|
usage = _read_int(_CGROUP_V1 / "memory.usage_in_bytes")
|
|
cache = _stat_value(_CGROUP_V1 / "memory.stat", "total_inactive_file")
|
|
if limit is not None and limit >= 1 << 60: # v1 "unlimited" is a huge sentinel
|
|
limit = None
|
|
if usage is not None and cache:
|
|
usage = max(usage - cache, 0)
|
|
return limit, usage
|
|
|
|
|
|
def memory_info() -> MemoryInfo:
|
|
info = _meminfo()
|
|
host_available = info.get("MemAvailable")
|
|
host_total = info.get("MemTotal")
|
|
limit, usage = _cgroup_limit_and_usage()
|
|
candidates = [x for x in (host_available, (limit - usage) if limit is not None and usage is not None else None)
|
|
if x is not None]
|
|
available = min(candidates) if candidates else None
|
|
limit_bytes = limit if limit is not None else host_total
|
|
return MemoryInfo(available_mb=None if available is None else max(0, available // _MIB),
|
|
limit_mb=None if limit_bytes is None else limit_bytes // _MIB)
|
|
|
|
|
|
def min_free_mb() -> int:
|
|
"""``bot_desktop.min_free_memory_mb``; 0 disables the gate. A hosted deployment sets it in the
|
|
instance's config.yaml (or the managed overlay), not an env var."""
|
|
from hermes_cli.config import load_config_readonly
|
|
cfg = load_config_readonly().get("bot_desktop") or {}
|
|
try:
|
|
return max(0, int(cfg.get("min_free_memory_mb", DEFAULT_MIN_FREE_MB)))
|
|
except (TypeError, ValueError):
|
|
return DEFAULT_MIN_FREE_MB
|
|
|
|
|
|
def tight_headroom_mb(floor: Optional[int] = None) -> int:
|
|
"""Above the floor but below this, a start is allowed and logged: the desktop fits, a few browser tabs
|
|
would not. Derived from the floor so raising the floor cannot silently retire the warning."""
|
|
floor = min_free_mb() if floor is None else floor
|
|
return floor + floor // 3
|
|
|
|
|
|
def memory_blocker(info: Optional[MemoryInfo] = None, need: Optional[int] = None) -> Optional[str]:
|
|
"""Why the screen must not start now, or None. Unknown memory is not a blocker: a host we cannot
|
|
read is not a host we know to be small. ``need`` lets a caller that also wants
|
|
:func:`tight_headroom_mb` read the floor once instead of loading the config twice."""
|
|
need = min_free_mb() if need is None else need
|
|
if need == 0:
|
|
return None
|
|
info = info or memory_info()
|
|
if info.available_mb is None or info.available_mb >= need:
|
|
return None
|
|
limit = f" of {info.limit_mb} MB" if info.limit_mb else ""
|
|
return (f"Not enough free memory for a desktop: {info.available_mb} MB available{limit}, "
|
|
f"{need} MB needed (bot_desktop.min_free_memory_mb)")
|