Merge branch 'bs5-a' into hermes/hermes-b802e898
This commit is contained in:
@@ -1075,6 +1075,25 @@ def _stop_profile_backends(canon: str, profile_dir: Path) -> None:
|
||||
print(f"✓ Stopped {len(pids)} profile backend process(es)")
|
||||
|
||||
|
||||
def _stop_bot_desktop(profile_dir: Path) -> None:
|
||||
"""Stop the profile's Bot Desktop (Xvnc + Xfce launcher) before its directory is removed or renamed;
|
||||
gateway shutdown does not reach it (its own session, its own pid file). Scoped through the hermes-home
|
||||
override so the runtime reads THIS profile's bot-desktop/ state, whichever profile invoked the op.
|
||||
A failure here is logged, never fatal: the profile op is what the user asked for."""
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
from tools.bot_desktop import runtime
|
||||
if not runtime.is_supported_host():
|
||||
return
|
||||
token = set_hermes_home_override(profile_dir)
|
||||
try:
|
||||
if runtime.stop():
|
||||
print("✓ Bot Desktop stopped")
|
||||
except Exception as e:
|
||||
logger.warning("Could not stop the Bot Desktop of %s: %s", profile_dir, e)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
|
||||
def _rmtree_make_writable(func, path, exc):
|
||||
"""onexc/onerror handler: add +w on PermissionError so rmtree can proceed. Covers NixOS-
|
||||
style read-only copies where the path itself (0444) or its parent (0555) isn't writable."""
|
||||
@@ -1168,6 +1187,7 @@ def delete_profile(name: str, yes: bool = False) -> Path:
|
||||
if gw_running:
|
||||
_stop_gateway_process(profile_dir)
|
||||
_stop_profile_backends(canon, profile_dir)
|
||||
_stop_bot_desktop(profile_dir)
|
||||
|
||||
# Tombstone before rmtree so a stale serve/logging mkdir cannot relist this name live.
|
||||
mark_named_profile_deleted(profile_dir)
|
||||
@@ -1472,8 +1492,9 @@ def _default_export_ignore(root_dir: Path):
|
||||
return _ignore
|
||||
|
||||
|
||||
# Credential files dropped from named-profile exports.
|
||||
_EXPORT_CREDENTIAL_FILES = frozenset({"auth.json", ".env"})
|
||||
# Credential files dropped from named-profile exports. ``bot-desktop`` is the screen's runtime state:
|
||||
# its persistent Chromium profile (Cookies, Login Data — the bot's live web sessions), Xauthority, sockets.
|
||||
_EXPORT_CREDENTIAL_FILES = frozenset({"auth.json", ".env", "bot-desktop"})
|
||||
|
||||
# Text/config suffixes secret-scrubbed on export; binary DBs, images etc. are left alone.
|
||||
_EXPORT_REDACT_SUFFIXES = frozenset({
|
||||
@@ -1662,10 +1683,11 @@ def rename_profile(old_name: str, new_name: str) -> Path:
|
||||
if new_dir.exists():
|
||||
raise FileExistsError(f"Profile '{new_canon}' already exists.")
|
||||
|
||||
# 1. Stop gateway if running
|
||||
# 1. Stop gateway if running, and the screen whose launcher holds paths under the old name
|
||||
if _check_gateway_running(old_dir):
|
||||
_cleanup_gateway_service(old_canon, old_dir)
|
||||
_stop_gateway_process(old_dir)
|
||||
_stop_bot_desktop(old_dir)
|
||||
|
||||
# 2. Rename directory
|
||||
old_dir.rename(new_dir)
|
||||
|
||||
@@ -1161,3 +1161,55 @@ class TestResolveProfileEnvSpelling:
|
||||
assert Path(resolve_profile_env("default")) == _get_default_hermes_home()
|
||||
|
||||
|
||||
|
||||
|
||||
def _live_bot_desktop_launcher(profile_dir: Path):
|
||||
"""A synthetic Bot Desktop launcher for ``profile_dir``: its own session (like launcher.sh) with the
|
||||
identity file + env runtime.status() reads, so the profile op sees a running screen."""
|
||||
import subprocess
|
||||
from tools.bot_desktop import runtime
|
||||
|
||||
proc = subprocess.Popen(["sleep", "60"], start_new_session=True)
|
||||
sd = profile_dir / "bot-desktop"
|
||||
sd.mkdir()
|
||||
(sd / "launcher.pid").write_text(f"{proc.pid} {runtime._create_time(proc.pid)}", encoding="utf-8")
|
||||
(sd / "env").write_text("DISPLAY=:42\n", encoding="utf-8")
|
||||
return proc
|
||||
|
||||
|
||||
@pytest.mark.linux_only
|
||||
@pytest.mark.parametrize("op", ["delete", "rename"])
|
||||
def test_profile_delete_and_rename_stop_the_profiles_bot_desktop(profile_env, op):
|
||||
"""Deleting or renaming a profile stops its gateway, and must stop its Bot Desktop launcher too: the
|
||||
Xvnc/Xfce session otherwise keeps running against a directory that no longer exists (or now belongs to
|
||||
another name), holding its display number and an rfb.sock nobody can reach through status()."""
|
||||
profile_dir = create_profile("coder", no_alias=True)
|
||||
proc = _live_bot_desktop_launcher(profile_dir)
|
||||
try:
|
||||
with patch("hermes_cli.profiles._cleanup_gateway_service"), \
|
||||
patch("hermes_cli.profiles.check_alias_collision", return_value="skip"):
|
||||
if op == "delete":
|
||||
delete_profile("coder", yes=True)
|
||||
else:
|
||||
rename_profile("coder", "hacker")
|
||||
assert proc.wait(timeout=10) != 0, "the launcher was signalled by the profile op"
|
||||
finally:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["coder", "default"])
|
||||
def test_export_leaves_the_bot_desktop_browser_profile_out(profile_env, tmp_path, name):
|
||||
"""bot-desktop/ holds the screen's persistent Chromium profile (Cookies, Login Data: the bot's live web
|
||||
sessions) plus sockets and X state. None of it belongs in an export archive meant to move a persona."""
|
||||
profile_dir = create_profile(name, no_alias=True) if name != "default" else get_profile_dir("default")
|
||||
(profile_dir / "config.yaml").write_text("model: test")
|
||||
cookies = profile_dir / "bot-desktop" / "browser-profile" / "Default" / "Cookies"
|
||||
cookies.parent.mkdir(parents=True)
|
||||
cookies.write_bytes(b"SQLite format 3\x00")
|
||||
output = tmp_path / "export" / f"{name}.tar.gz"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
export_profile(name, str(output))
|
||||
with tarfile.open(str(output), "r:gz") as tf:
|
||||
names = tf.getnames()
|
||||
assert f"{name}/config.yaml" in names
|
||||
assert not [n for n in names if "bot-desktop" in n], names
|
||||
|
||||
@@ -178,3 +178,26 @@ def test_lease_works_without_fcntl(tmp_path):
|
||||
out = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
stdin=subprocess.DEVNULL, env={**os.environ, "HERMES_HOME": str(tmp_path)})
|
||||
assert out.stdout.strip() == "OK", out.stderr
|
||||
|
||||
|
||||
@pytest.mark.linux_only
|
||||
def test_lease_files_are_private_even_when_the_lease_is_written_before_the_screen_exists(tmp_path, monkeypatch):
|
||||
"""A takeover can be recorded before start() ever created bot-desktop/ 0700. The lease path then created
|
||||
the directory and files with the umask (0755 / 0644): who holds the screen, and the lock the RFB bridge
|
||||
serialises on, readable and clobberable by every other local user. Every piece must be owner-only."""
|
||||
import os
|
||||
import stat
|
||||
|
||||
home = tmp_path / "deep" / "home"
|
||||
home.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(lease, "get_hermes_home", lambda: home)
|
||||
old = os.umask(0o022)
|
||||
try:
|
||||
lease.acquire("v1")
|
||||
finally:
|
||||
os.umask(old)
|
||||
sd = home / "bot-desktop"
|
||||
for path in (sd, sd / "lease.json", sd / "lease.lock"):
|
||||
assert path.exists(), path
|
||||
assert stat.S_IMODE(path.stat().st_mode) & 0o077 == 0, f"{path.name} is {oct(path.stat().st_mode)}"
|
||||
|
||||
@@ -144,3 +144,113 @@ def test_concurrent_starts_of_one_profile_spawn_one_launcher(tmp_path, start_in_
|
||||
out = _collect([start_in_fresh_process(tmp_path / "a"), start_in_fresh_process(tmp_path / "a")])
|
||||
assert len({o["pid"] for o in out}) == 1, out
|
||||
assert len(list((tmp_path / "xlocks").glob("spawned.*"))) == 1
|
||||
|
||||
|
||||
_ORPHANING_LAUNCHER = """#!/usr/bin/env bash
|
||||
# Stands in for launcher.sh whose Xvnc child ("sleep") lives in the launcher's process group and
|
||||
# outlives a SIGKILL of the launcher itself — the X lock names the child, as the real one does.
|
||||
: > "$HERMES_BD_XLOCK_DIR/spawned.$$"
|
||||
sleep 30 &
|
||||
echo $! > "$HERMES_BD_XLOCK_DIR/.X${HERMES_BD_DISPLAY_NUM}-lock"
|
||||
: > "$HERMES_BD_SOCKET"
|
||||
printf 'DISPLAY=:%s\\n' "$HERMES_BD_DISPLAY_NUM" > "$HERMES_BD_ENV_FILE"
|
||||
wait
|
||||
"""
|
||||
|
||||
|
||||
def _gone(pid: int) -> bool:
|
||||
import psutil
|
||||
try:
|
||||
return psutil.Process(pid).status() == psutil.STATUS_ZOMBIE
|
||||
except psutil.NoSuchProcess:
|
||||
return True
|
||||
|
||||
|
||||
def _wait_until(pred, timeout=5.0) -> bool:
|
||||
import time
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if pred():
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return pred()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def in_process_runtime(tmp_path, monkeypatch):
|
||||
"""runtime.start()/stop() against a scratch state dir and a fake launcher script (set by the test)."""
|
||||
import os
|
||||
|
||||
home = tmp_path / "home"
|
||||
(tmp_path / "xlocks").mkdir()
|
||||
monkeypatch.setattr(runtime, "state_dir", lambda: home / "bot-desktop")
|
||||
monkeypatch.setattr(runtime, "_LAUNCHER", tmp_path / "launcher.sh")
|
||||
monkeypatch.setattr(runtime, "_X_LOCK_DIR", tmp_path / "xlocks")
|
||||
monkeypatch.setattr(runtime, "_ALLOC_LOCK", tmp_path / "alloc.lock")
|
||||
monkeypatch.setattr(runtime, "missing_binaries", lambda: [])
|
||||
monkeypatch.setattr(runtime, "geometry", lambda: "800x600")
|
||||
monkeypatch.setenv("HERMES_BD_XLOCK_DIR", str(tmp_path / "xlocks"))
|
||||
yield tmp_path
|
||||
with contextlib.suppress(Exception):
|
||||
runtime.stop()
|
||||
for lock in (tmp_path / "xlocks").glob(".X*-lock"): # anything the code under test failed to reap
|
||||
with contextlib.suppress(OSError, ValueError):
|
||||
os.kill(int(lock.read_text()), 9)
|
||||
|
||||
|
||||
@pytest.mark.linux_only
|
||||
@pytest.mark.live_system_guard_bypass # the orphan is reparented to init: signalling it is the point
|
||||
def test_orphaned_x_server_of_a_dead_launcher_is_reaped_on_next_start(in_process_runtime):
|
||||
"""SIGKILL the launcher and its Xvnc survives, holding the display and rfb.sock. status() keys on the
|
||||
launcher pid and says stopped; start() must find that orphan through the recorded display's X lock
|
||||
and kill it instead of allocating a second server beside it (two servers, one socket path)."""
|
||||
import os
|
||||
import signal
|
||||
|
||||
scratch = in_process_runtime
|
||||
(scratch / "launcher.sh").write_text(_ORPHANING_LAUNCHER, encoding="utf-8")
|
||||
first = runtime.start(wait_seconds=10)
|
||||
lock = scratch / "xlocks" / f".X{first.display.lstrip(':')}-lock"
|
||||
orphan = int(lock.read_text())
|
||||
os.kill(first.pid, signal.SIGKILL)
|
||||
assert _wait_until(lambda: _gone(first.pid))
|
||||
assert not _gone(orphan), "the X server outlives its launcher (that is the bug's precondition)"
|
||||
assert runtime.status().running is False
|
||||
|
||||
second = runtime.start(wait_seconds=10)
|
||||
assert second.pid != first.pid and second.running
|
||||
assert _wait_until(lambda: _gone(orphan)), "the dead launcher's X server must be reaped, not leaked"
|
||||
assert runtime.stop() is True
|
||||
|
||||
|
||||
_SLOW_LAUNCHER = """#!/usr/bin/env bash
|
||||
# Publishes only AFTER runtime.start()'s readiness deadline has passed.
|
||||
sleep 30 &
|
||||
echo $! > "$HERMES_BD_XLOCK_DIR/.X${HERMES_BD_DISPLAY_NUM}-lock"
|
||||
sleep 1
|
||||
: > "$HERMES_BD_SOCKET"
|
||||
printf 'DISPLAY=:%s\\n' "$HERMES_BD_DISPLAY_NUM" > "$HERMES_BD_ENV_FILE"
|
||||
wait
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.linux_only
|
||||
@pytest.mark.live_system_guard_bypass # the launcher's group must really be signalled
|
||||
def test_readiness_timeout_terminates_the_launch_it_gave_up_on(in_process_runtime):
|
||||
"""When the launcher misses the readiness deadline start() raises — and must take the launch down with
|
||||
it. It used to leave the launcher running; the child then published DISPLAY/rfb.sock a moment later and
|
||||
a screen nobody asked for (and whose start() had reported failure) stayed up behind a 'running' status."""
|
||||
import time
|
||||
|
||||
scratch = in_process_runtime
|
||||
(scratch / "launcher.sh").write_text(_SLOW_LAUNCHER, encoding="utf-8")
|
||||
sd = runtime.state_dir()
|
||||
with pytest.raises(RuntimeError, match="did not publish"):
|
||||
runtime.start(wait_seconds=0.05)
|
||||
launcher = runtime._recorded_launcher_pid()
|
||||
assert launcher is None or _gone(launcher), "the timed-out launcher must be reaped, not left to publish later"
|
||||
time.sleep(1.5) # past the slow launcher's publish time
|
||||
assert not (sd / "env").exists() and not (sd / "rfb.sock").exists()
|
||||
assert runtime.status().running is False
|
||||
for lock in (scratch / "xlocks").glob(".X*-lock"):
|
||||
assert _gone(int(lock.read_text())), "the launch's X server must die with its launcher"
|
||||
|
||||
@@ -49,7 +49,10 @@ if [[ -e "$xlock" ]] && ! kill -0 "$(tr -d ' ' < "$xlock" 2>/dev/null)" 2>/dev/n
|
||||
rm -f "$xlock" "/tmp/.X11-unix/X${HERMES_BD_DISPLAY_NUM}"
|
||||
fi
|
||||
: > "$XAUTHORITY"; chmod 600 "$XAUTHORITY"
|
||||
xauth -q -f "$XAUTHORITY" add "$DISPLAY" MIT-MAGIC-COOKIE-1 "$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')"
|
||||
# The cookie goes in on stdin, not argv: a command line is readable by every local user via ps.
|
||||
xauth -q -f "$XAUTHORITY" source - <<COOKIE
|
||||
add $DISPLAY MIT-MAGIC-COOKIE-1 $(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')
|
||||
COOKIE
|
||||
|
||||
# ---- look: dark theme from whatever the host ships (first match wins), Hermes wallpaper ----
|
||||
pick_theme() { local d t; for t in "$@"; do for d in /usr/share/themes "$HOME/.themes"; do [[ -d "$d/$t" ]] && { echo "$t"; return; }; done; done; echo "$1"; }
|
||||
|
||||
@@ -23,7 +23,7 @@ from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home, hermes_home_key
|
||||
from hermes_constants import get_hermes_home, hermes_home_key, secure_parent_dir
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
@@ -87,10 +87,23 @@ def _read(path: Path) -> Lease:
|
||||
return Lease(holder=HUMAN, viewer_id="unreadable-lease", reason="lease file corrupt")
|
||||
|
||||
|
||||
def _write(path: Path, lease: Lease) -> None:
|
||||
def _private_dir(path: Path) -> None:
|
||||
"""``bot-desktop/`` owner-only even when the lease is the first thing written there (a takeover can be
|
||||
recorded before start() ever ran, and the umask would otherwise leave it 0755)."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
secure_parent_dir(path)
|
||||
|
||||
|
||||
def _open_private(path: str | bytes | os.PathLike, flags: int) -> int:
|
||||
"""``open(..., opener=_open_private)``: the file is created 0600 regardless of the umask."""
|
||||
return os.open(path, flags, 0o600)
|
||||
|
||||
|
||||
def _write(path: Path, lease: Lease) -> None:
|
||||
_private_dir(path)
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(lease.as_dict()), encoding="utf-8")
|
||||
with open(tmp, "w", encoding="utf-8", opener=_open_private) as fh:
|
||||
fh.write(json.dumps(lease.as_dict()))
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
@@ -104,8 +117,8 @@ class _locked:
|
||||
def __enter__(self):
|
||||
if fcntl is None:
|
||||
return self
|
||||
self._lockfile.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._fh = open(self._lockfile, "a+", encoding="utf-8") # noqa: SIM115 — closed in __exit__
|
||||
_private_dir(self._lockfile)
|
||||
self._fh = open(self._lockfile, "a+", encoding="utf-8", opener=_open_private) # noqa: SIM115 — closed in __exit__
|
||||
fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX)
|
||||
return self
|
||||
|
||||
|
||||
@@ -48,8 +48,10 @@ BINARY_PACKAGES = {
|
||||
"apt": {"Xvnc": "tigervnc-standalone-server", "xfwm4": "xfwm4", "xfce4-panel": "xfce4-panel",
|
||||
"xfdesktop": "xfdesktop4", "xfsettingsd": "xfce4-settings", "dbus-run-session": "dbus-x11",
|
||||
"xauth": "xauth", "xdpyinfo": "x11-utils", "setxkbmap": "x11-xkb-utils", "xprop": "x11-utils"},
|
||||
"dnf": {"Xvnc": "tigervnc-server-minimal", "xfwm4": "xfwm4", "xfce4-panel": "xfce4-panel",
|
||||
"xfdesktop": "xfdesktop", "xfsettingsd": "xfce4-settings", "dbus-run-session": "dbus-x11",
|
||||
# tigervnc-x11-server is the real package (tigervnc-server-minimal is only a Provides on it); dbus-run-session
|
||||
# is in dbus-daemon (dbus-x11 ships dbus-launch only).
|
||||
"dnf": {"Xvnc": "tigervnc-x11-server", "xfwm4": "xfwm4", "xfce4-panel": "xfce4-panel",
|
||||
"xfdesktop": "xfdesktop", "xfsettingsd": "xfce4-settings", "dbus-run-session": "dbus-daemon",
|
||||
"xauth": "xorg-x11-xauth", "xdpyinfo": "xdpyinfo", "setxkbmap": "setxkbmap", "xprop": "xprop"},
|
||||
"pacman": {"Xvnc": "tigervnc", "xfwm4": "xfwm4", "xfce4-panel": "xfce4-panel", "xfdesktop": "xfdesktop",
|
||||
"xfsettingsd": "xfce4-settings", "dbus-run-session": "dbus",
|
||||
@@ -60,8 +62,8 @@ PACKAGES = {
|
||||
"apt": ["tigervnc-standalone-server", "xfce4-panel", "xfwm4", "xfdesktop4", "xfce4-settings",
|
||||
"xfce4-terminal", "dbus-x11", "x11-xserver-utils", "x11-utils", "x11-xkb-utils", "xauth",
|
||||
"fonts-dejavu-core"],
|
||||
"dnf": ["tigervnc-server-minimal", "xfce4-panel", "xfwm4", "xfdesktop", "xfce4-settings",
|
||||
"xfce4-terminal", "dbus-x11", "xsetroot", "xset", "xdpyinfo", "xprop", "xorg-x11-xauth", "setxkbmap",
|
||||
"dnf": ["tigervnc-x11-server", "xfce4-panel", "xfwm4", "xfdesktop", "xfce4-settings",
|
||||
"xfce4-terminal", "dbus-daemon", "xsetroot", "xset", "xdpyinfo", "xprop", "xorg-x11-xauth", "setxkbmap",
|
||||
"dejavu-sans-fonts"],
|
||||
"pacman": ["tigervnc", "xfce4-panel", "xfwm4", "xfdesktop", "xfce4-settings", "xfce4-terminal", "dbus",
|
||||
"xorg-xsetroot", "xorg-xset", "xorg-xdpyinfo", "xorg-xprop", "xorg-xauth", "xorg-setxkbmap",
|
||||
@@ -125,8 +127,13 @@ def _read(path: Path) -> Optional[str]:
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""A zombie is dead for our purposes: a SIGKILLed launcher stays a zombie in the gateway until the next
|
||||
Popen reaps it, and reporting it as running would hide its orphaned X server behind a live status."""
|
||||
import psutil
|
||||
return psutil.pid_exists(pid)
|
||||
try:
|
||||
return psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
|
||||
except psutil.Error:
|
||||
return False
|
||||
|
||||
|
||||
def _create_time(pid: int) -> Optional[float]:
|
||||
@@ -150,24 +157,83 @@ def _launcher_pid() -> Optional[int]:
|
||||
except ValueError:
|
||||
return None
|
||||
actual = _create_time(pid)
|
||||
return pid if actual is not None and abs(actual - born) < 0.01 else None
|
||||
return pid if actual is not None and abs(actual - born) < 0.01 and _pid_alive(pid) else None
|
||||
|
||||
|
||||
def _recorded_launcher_pid() -> Optional[int]:
|
||||
"""The pid ``launcher.pid`` names, alive or not (the orphan sweep matches process groups against it)."""
|
||||
pid_s, _, born_s = (_read(state_dir() / "launcher.pid") or "").partition(" ")
|
||||
return int(pid_s) if pid_s.isdigit() and born_s else None
|
||||
|
||||
|
||||
_X_LOCK_DIR = Path("/tmp") # where X servers write .X<n>-lock (tests point it at a scratch dir)
|
||||
|
||||
|
||||
def _x_lock_pid(num: int) -> Optional[int]:
|
||||
try:
|
||||
return int((_X_LOCK_DIR / f".X{num}-lock").read_text(encoding="utf-8").strip())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _display_in_use(num: int) -> bool:
|
||||
"""A live X server owns ``:num``: its lock file names a running pid. A lock left by a crashed
|
||||
server (dead pid) does not count, so the number can be reclaimed."""
|
||||
lock = _X_LOCK_DIR / f".X{num}-lock"
|
||||
try:
|
||||
pid = int(lock.read_text(encoding="utf-8").strip())
|
||||
except (OSError, ValueError):
|
||||
pid = _x_lock_pid(num)
|
||||
return pid is not None and _pid_alive(pid)
|
||||
|
||||
|
||||
def _reap_orphaned_server(sd: Path) -> bool:
|
||||
"""Caller holds ``start.lock`` and has established that no live launcher exists. The launcher runs Xvnc in
|
||||
its own session, so a SIGKILLed launcher leaves the X server alive, holding the display and ``rfb.sock``;
|
||||
``status()`` keys on the launcher and says stopped, and a naive restart allocates a second server next
|
||||
to it and overwrites the socket path both now claim. The X lock of the recorded display names that
|
||||
server: it is ours when it sits in the dead launcher's process group or its command line binds OUR
|
||||
socket. Kill it (group first), drop the state it left, and report whether anything was signalled."""
|
||||
import psutil
|
||||
|
||||
recorded = _read(sd / "display")
|
||||
pid = _x_lock_pid(int(recorded)) if recorded and recorded.isdigit() else None
|
||||
if pid is None or not _pid_alive(pid):
|
||||
return False
|
||||
return _pid_alive(pid)
|
||||
launcher = _recorded_launcher_pid()
|
||||
try:
|
||||
pgid = os.getpgid(pid) # windows-footgun: ok — Linux-only runtime (is_supported_host gates start/stop)
|
||||
cmdline = psutil.Process(pid).cmdline()
|
||||
except (ProcessLookupError, psutil.Error):
|
||||
return False
|
||||
binds_our_socket = "Xvnc" in Path(cmdline[0] if cmdline else "").name and str(sd / "rfb.sock") in cmdline
|
||||
if pgid != launcher and not binds_our_socket:
|
||||
return False # somebody else's server took the number after we died; never touch it
|
||||
logger.warning("Bot Desktop launcher %s is gone but its X server (pid %s) survived on :%s; reaping",
|
||||
launcher, pid, recorded)
|
||||
_kill_group_then_wait(pgid if pgid == launcher else None, pid)
|
||||
(_X_LOCK_DIR / f".X{recorded}-lock").unlink(missing_ok=True)
|
||||
for name in ("launcher.pid", "env", "rfb.sock"):
|
||||
(sd / name).unlink(missing_ok=True)
|
||||
return True
|
||||
|
||||
|
||||
_ALLOC_LOCK = Path("/tmp/.hermes-bot-desktop-alloc.lock") # host-wide: profiles allocate from one band
|
||||
def _kill_group_then_wait(pgid: Optional[int], pid: int, grace: float = 2.0) -> None:
|
||||
"""SIGTERM the group (or the lone pid), SIGKILL whatever is still there after ``grace``."""
|
||||
def _signal(sig: int) -> None:
|
||||
with contextlib.suppress(ProcessLookupError, PermissionError):
|
||||
if pgid is not None:
|
||||
os.killpg(pgid, sig) # windows-footgun: ok — Linux-only runtime (is_supported_host gates start/stop)
|
||||
else:
|
||||
os.kill(pid, sig)
|
||||
_signal(signal.SIGTERM)
|
||||
deadline = time.monotonic() + grace
|
||||
while time.monotonic() < deadline:
|
||||
if not _pid_alive(pid):
|
||||
return
|
||||
time.sleep(0.05)
|
||||
_signal(signal.SIGKILL) # windows-footgun: ok — Linux-only runtime (is_supported_host gates start/stop)
|
||||
|
||||
|
||||
# Host-wide (every profile allocates from one band), so it lives outside any profile home — but not in
|
||||
# world-writable /tmp, where a predictable name lets another local user pre-create or squat the file.
|
||||
_ALLOC_LOCK = Path(os.environ.get("XDG_RUNTIME_DIR") or Path.home() / ".cache") / "hermes-bot-desktop-alloc.lock"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -194,6 +260,7 @@ def _pick_display() -> int:
|
||||
|
||||
|
||||
def _allocate_display() -> int:
|
||||
_ALLOC_LOCK.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
with _flocked(_ALLOC_LOCK):
|
||||
return _pick_display()
|
||||
|
||||
@@ -307,6 +374,9 @@ def start(*, wait_seconds: float = 15.0) -> DesktopStatus:
|
||||
with _flocked(sd / "start.lock"):
|
||||
if _launcher_pid() is not None and published_env().get("DISPLAY"):
|
||||
return status()
|
||||
if _launcher_pid() is None:
|
||||
_reap_orphaned_server(sd)
|
||||
_ALLOC_LOCK.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
with _flocked(_ALLOC_LOCK):
|
||||
return _spawn_and_wait(sd, _pick_display(), wait_seconds)
|
||||
|
||||
@@ -348,11 +418,19 @@ def _spawn_and_wait(sd: Path, num: int, wait_seconds: float) -> DesktopStatus:
|
||||
logger.info("Bot Desktop for profile %s up on :%s", _profile_name(), num)
|
||||
return status()
|
||||
time.sleep(0.1)
|
||||
# Giving up must take the launch down: left alone, the launcher publishes DISPLAY and rfb.sock a moment
|
||||
# later and a screen whose start() reported failure stays up as "running". The launcher is its own
|
||||
# session leader, so its group is exactly this launch (Xvnc, dbus, Xfce) and nothing else.
|
||||
_kill_group_then_wait(proc.pid, proc.pid)
|
||||
proc.wait()
|
||||
for name in ("launcher.pid", "env", "rfb.sock"):
|
||||
(sd / name).unlink(missing_ok=True)
|
||||
raise RuntimeError(f"Bot Desktop did not publish its display within {wait_seconds:.0f}s (see {sd / 'launcher.log'})")
|
||||
|
||||
|
||||
def stop() -> bool:
|
||||
"""Stop this profile's desktop; True when a running launcher was signalled."""
|
||||
"""Stop this profile's desktop; True when a running launcher (or the X server a dead one left behind)
|
||||
was signalled."""
|
||||
if not is_supported_host():
|
||||
return False
|
||||
sd = state_dir()
|
||||
@@ -364,22 +442,11 @@ def stop() -> bool:
|
||||
def _stop_locked(sd: Path) -> bool:
|
||||
pid = _launcher_pid()
|
||||
if pid is None:
|
||||
reaped = _reap_orphaned_server(sd)
|
||||
(sd / "env").unlink(missing_ok=True)
|
||||
return False
|
||||
return reaped
|
||||
# The launcher runs in its own session; killing the group takes Xvnc, dbus and Xfce with it.
|
||||
try:
|
||||
os.killpg(pid, signal.SIGTERM) # windows-footgun: ok — Linux-only runtime (is_supported_host gates start)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
for _ in range(50):
|
||||
if not _pid_alive(pid):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
try:
|
||||
os.killpg(pid, signal.SIGKILL) # windows-footgun: ok — Linux-only runtime (is_supported_host gates start)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
_kill_group_then_wait(pid, pid, grace=5.0)
|
||||
(sd / "launcher.pid").unlink(missing_ok=True)
|
||||
(sd / "env").unlink(missing_ok=True)
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user