"""Bot Desktop runtime: one headless Xfce desktop per Hermes profile, served over RFB on a private Unix socket, viewed and driven from Hermes Desktop. Layout under ``/bot-desktop/``: ``display`` (allocated X display number), ``rfb.sock`` (Xvnc RFB Unix socket, 0600), ``Xauthority``, ``env`` (DISPLAY/XAUTHORITY/DBUS_SESSION_BUS_ADDRESS published by the launcher once Xfce's bus exists), ``launcher.pid``, ``launcher.log``, ``xdg/`` (per-profile XDG_CONFIG_HOME so two profiles never share xfconf). Everything is profile-scoped via ``get_hermes_home()`` so N profiles in one gateway get N desktops: one screen per bot on the shared machine. The launcher is ``launcher.sh`` next to this module; :func:`desktop_env` is what cua-driver and headed Chromium spawns merge in so the agent acts on this profile's screen and nowhere else. """ from __future__ import annotations import contextlib import logging import os import shutil import signal import subprocess import sys import time from dataclasses import dataclass from pathlib import Path from typing import Dict, Optional from hermes_constants import get_hermes_home logger = logging.getLogger(__name__) _LAUNCHER = Path(__file__).with_name("launcher.sh") # Display numbers below 10 collide with real seats and default Xvfb recipes (:99 is popular too); scan a # private band and record the choice so restarts reuse it. _DISPLAY_MIN, _DISPLAY_MAX = 20, 89 # Binaries the launcher execs; the package hint is per distro family. REQUIRED_BINARIES = ("Xvnc", "xfwm4", "xfce4-panel", "xfdesktop", "xfsettingsd", "dbus-run-session", "xauth", "xdpyinfo", "setxkbmap", "xprop") # Which package in each distro list ships each required binary. Fedora retired the xorg-x11-utils / # xorg-x11-server-utils umbrellas (per-binary packages since F35) and dnf5 refuses the whole transaction on # one unknown name, so every binary must map to a package that still resolves; the test suite checks that # each mapped package is in PACKAGES for its manager. 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"}, # 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", "xauth": "xorg-xauth", "xdpyinfo": "xorg-xdpyinfo", "setxkbmap": "xorg-setxkbmap", "xprop": "xorg-xprop"}, } 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-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", "ttf-dejavu"], } def state_dir() -> Path: return get_hermes_home() / "bot-desktop" def is_supported_host() -> bool: return sys.platform.startswith("linux") def missing_binaries() -> list[str]: return [b for b in REQUIRED_BINARIES if shutil.which(b) is None] def package_manager() -> Optional[str]: for pm in ("apt-get", "dnf", "pacman"): if shutil.which(pm): return "apt" if pm == "apt-get" else pm return None def install_command() -> Optional[str]: """The distro command that installs the Bot Desktop packages, as the human would type it on THIS host: prefixed with ``sudo`` unless Hermes already runs as root, so it is both what the pane shows and what :mod:`tools.bot_desktop.install` runs. ``None`` when no package manager is present. Not a promise that it can run here: see :func:`installable`. The published Docker image supervises every service under ``s6-setuidgid hermes`` (UID 10000 by default) and ships no ``sudo`` binary, so an install on a hosted instance is impossible no matter what this returns.""" pm = package_manager() if pm is None: return None pkgs = " ".join(PACKAGES[pm]) body = { "apt": f"apt-get install -y --no-install-recommends {pkgs}", "dnf": f"dnf install -y {pkgs}", "pacman": f"pacman -S --needed --noconfirm {pkgs}", }[pm] return body if is_root() else f"sudo {body}" def is_root() -> bool: return hasattr(os, "geteuid") and os.geteuid() == 0 def installable() -> bool: """Whether :func:`install_command` could actually succeed on this host. False on an unprivileged process with no ``sudo`` to reach for, which is exactly the published Docker image: services drop to the ``hermes`` user and no ``sudo`` binary is installed. The packages can only arrive in the image there, so :func:`start` says that instead of printing a sudo line the user has no way to run. ``status()`` still reports ``install_command`` for the pane; surfacing this there needs a wire-contract change and is deliberately out of scope. """ if package_manager() is None: return False return is_root() or shutil.which("sudo") is not None @dataclass class DesktopStatus: profile: str supported: bool installed: bool missing: list[str] running: bool pid: Optional[int] display: Optional[str] socket: Optional[str] geometry: str install_command: Optional[str] browser: Optional[str] # headed Chromium the dock's Browser icon and agent-browser share; None = no headed browser blocker: Optional[str] = None # why start() would refuse right now (memory); None = may start memory_available_mb: Optional[int] = None memory_limit_mb: Optional[int] = None def as_dict(self) -> Dict[str, object]: return dict(self.__dict__) def _read(path: Path) -> Optional[str]: try: return path.read_text(encoding="utf-8-sig").strip() or None except OSError: return None 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 try: return psutil.Process(pid).status() != psutil.STATUS_ZOMBIE except psutil.Error: return False def _create_time(pid: int) -> Optional[float]: import psutil try: return psutil.Process(pid).create_time() except (psutil.Error, OverflowError, ValueError): return None def _launcher_pid() -> Optional[int]: """The live launcher's pid, or None. ``launcher.pid`` holds ``" "``: a recycled pid with a different start time is somebody else's process and must never be reported as ours nor killed by :func:`stop`. The pre-identity single-number format is treated as not running.""" raw = _read(state_dir() / "launcher.pid") pid_s, _, born_s = (raw or "").partition(" ") if not pid_s.isdigit() or not born_s: return None try: pid, born = int(pid_s), float(born_s) except ValueError: return None actual = _create_time(pid) 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") # no-tmp: ok — X servers write .X-lock here by protocol (tests point it at a scratch dir) _X_UNIX_TABLE = Path("/proc/net/unix") # the kernel's list of bound Unix sockets (tests point it at a fixture) def _x_lock_pid(num: int) -> Optional[int]: try: return int((_X_LOCK_DIR / f".X{num}-lock").read_text(encoding="utf-8-sig").strip()) except (OSError, ValueError): return None def _x_socket_bound(num: int) -> bool: """A running X server keeps ``@/tmp/.X11-unix/X`` (abstract namespace) bound for its whole life; the kernel drops it only when the process exits. A /tmp reaper can remove the lock file under a live Xvnc, and only this binding then still says the number is taken (a new server on it dies 'already running').""" try: lines = _X_UNIX_TABLE.read_text(encoding="utf-8-sig").splitlines() except OSError: return False # no-tmp: ok — detects the X server's display socket at the path the X11 protocol fixes return any(line.split()[-1].lstrip("@") == f"/tmp/.X11-unix/X{num}" for line in lines if line.strip()) def _display_in_use(num: int) -> bool: """A live X server owns ``:num``: its lock file names a running pid, or its X11 socket is bound. A lock left by a crashed server (dead pid, no socket) does not count, so the number can be reclaimed.""" pid = _x_lock_pid(num) return (pid is not None and _pid_alive(pid)) or _x_socket_bound(num) 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. When a /tmp reaper took the lock too (or the failed launch dropped ``display``), the socket path on the Xvnc command line is the remaining handle — without it one X server leaks per occurrence. Kill it (group first), drop the state it left, and report whether anything was signalled.""" import psutil def _binds_our_socket(cmdline: list) -> bool: return "Xvnc" in Path(cmdline[0] if cmdline else "").name and str(sd / "rfb.sock") in cmdline 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): pid = next((p.pid for p in psutil.process_iter(["cmdline"]) if _binds_our_socket(p.info["cmdline"] or [])), None) if pid is None: return False 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 if pgid != launcher and not _binds_our_socket(cmdline): 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 or "?") _kill_group_then_wait(pgid if pgid == launcher else None, pid) if recorded: (_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 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) def _anything_left() -> bool: # The leader dying first is the common case (bash exits on TERM, Xvnc traps it); the group is # done only when killpg(0) finds nobody, else a TERM-ignoring descendant keeps the display. if pgid is None: return _pid_alive(pid) try: os.killpg(pgid, 0) # windows-footgun: ok — Linux-only runtime (is_supported_host gates start/stop) except ProcessLookupError: return False except PermissionError: return True return True def _reap_if_ours() -> None: # The launcher was Popen'd by whichever gateway started it; a later gateway that stops it holds # no Popen, so the dead leader would sit as a zombie in our table (and count as "left"). with contextlib.suppress(ChildProcessError, OSError): os.waitpid(pid, os.WNOHANG) # windows-footgun: ok — Linux-only runtime (is_supported_host gates start/stop) _signal(signal.SIGTERM) deadline = time.monotonic() + grace while time.monotonic() < deadline: _reap_if_ours() if not _anything_left(): return time.sleep(0.05) _signal(signal.SIGKILL) # windows-footgun: ok — Linux-only runtime (is_supported_host gates start/stop) time.sleep(0.1) _reap_if_ours() # Host-wide (every profile allocates from one band), so it lives outside any profile home. A predictable # name must not be squattable: XDG_RUNTIME_DIR is the boundary — 0700 from logind, or from # docker/stage2-hook.sh in containers, which have none. _ALLOC_LOCK = Path(os.environ.get("XDG_RUNTIME_DIR") or Path.home() / ".cache") / "hermes-bot-desktop-alloc.lock" @contextlib.contextmanager def _flocked(path: Path): import fcntl # windows-footgun: ok — Linux-only runtime (is_supported_host gates start) with open(path, "a+", encoding="utf-8") as fh: # windows-footgun: ok — Linux-only runtime fcntl.flock(fh.fileno(), fcntl.LOCK_EX) try: yield fh finally: fcntl.flock(fh.fileno(), fcntl.LOCK_UN) def _pick_display() -> int: """Caller holds ``_ALLOC_LOCK``. The recorded number is only reused when no OTHER server holds it now: after profile A stops, B may have taken A's number, and A's launcher must never unlink B's socket.""" recorded = _read(state_dir() / "display") if recorded and recorded.isdigit() and not _display_in_use(int(recorded)): return int(recorded) for num in range(_DISPLAY_MIN, _DISPLAY_MAX + 1): if not _display_in_use(num): return num raise RuntimeError("no free X display number in the Bot Desktop band") def _allocate_display() -> int: _ALLOC_LOCK.parent.mkdir(parents=True, exist_ok=True, mode=0o700) with _flocked(_ALLOC_LOCK): return _pick_display() def desktop_env(base_env: Optional[Dict[str, str]] = None) -> Dict[str, str]: """``base_env`` (default ``os.environ``) with this profile's DISPLAY/XAUTHORITY/DBUS_SESSION_BUS_ADDRESS merged in when its desktop is running. Unchanged otherwise, so hosts with a real seat keep it. Pure: never starts anything (it is called from env builders, status probes and tests).""" env = dict(os.environ if base_env is None else base_env) published = published_env() if published: touch_activity() # a browser / cua-driver spawn is the agent using its screen env.update(published) env.pop("WAYLAND_DISPLAY", None) # X11 desktop; a leaked Wayland socket flips GTK/Chromium backends from tools.bot_desktop.browser import env_for_agent env_for_agent(env) # same binary + user-data-dir as the dock's Browser icon return env def ensure_started_for_tool() -> None: """Tool-boundary hook (``computer_use`` dispatch and the headed Chromium spawn sites of the browser tool): with ``bot_desktop.auto_start`` (opt-in, default off) a Linux host that has NO display and the packages installed gets its screen started on first use, so a headless gateway works the first time instead of answering "no DISPLAY is set". Failure is not an error here; the tool's own "no display" diagnosis is the right message then.""" if published_env(): touch_activity() return if not _should_auto_start(os.environ): return try: start() except Exception as exc: logger.info("Bot Desktop auto-start skipped: %s", exc) def _should_auto_start(env: Dict[str, str]) -> bool: if not is_supported_host() or env.get("DISPLAY") or env.get("WAYLAND_DISPLAY"): return False if missing_binaries(): return False from hermes_cli.config import load_config_readonly cfg = load_config_readonly().get("bot_desktop") or {} return bool(cfg.get("auto_start", False)) # ---- idle auto-stop ------------------------------------------------------------------------------- # A desktop nobody is using still holds ~220 MiB (plus whatever browser was left open). Every use — # a computer_use action, a browser spawn onto the screen, a viewer attached, a human takeover — stamps # ``activity``; the gateway's display watcher stops a screen idle past ``bot_desktop.idle_stop_minutes`` # unless a human holds it. The next use starts it again (auto_start or the pane's Start). DEFAULT_IDLE_STOP_MINUTES = 30 def touch_activity() -> None: path = state_dir() / "activity" try: path.touch() os.utime(path, None) except OSError: pass def idle_seconds() -> Optional[float]: """Seconds since the last stamped use; None when the screen never recorded one (falls back to the env file's publish time so a screen started and then forgotten still ages).""" for name in ("activity", "env"): try: return max(0.0, time.time() - (state_dir() / name).stat().st_mtime) except OSError: continue return None def idle_stop_seconds() -> float: from hermes_cli.config import load_config_readonly cfg = load_config_readonly().get("bot_desktop") or {} try: minutes = float(cfg.get("idle_stop_minutes", DEFAULT_IDLE_STOP_MINUTES)) except (TypeError, ValueError): minutes = DEFAULT_IDLE_STOP_MINUTES return max(0.0, minutes) * 60 def stop_if_idle() -> bool: """Stop this profile's screen when it has been idle past the limit and no human holds it. True when it was stopped.""" limit = idle_stop_seconds() if limit <= 0 or _launcher_pid() is None: return False idle = idle_seconds() if idle is None or idle < limit: return False from tools.bot_desktop import lease as _bd_lease if _bd_lease.get().holder == _bd_lease.HUMAN: return False logger.info("Bot Desktop for profile %s idle for %.0f min; stopping", _profile_name(), idle / 60) return stop() def published_env() -> Dict[str, str]: """Variables the launcher wrote once Xfce's private bus existed; empty when the desktop is down.""" if _launcher_pid() is None: return {} raw = _read(state_dir() / "env") if not raw: return {} out: Dict[str, str] = {} for line in raw.splitlines(): key, sep, value = line.partition("=") if sep: out[key.strip()] = value.strip() return out def rfb_socket_path() -> Optional[Path]: sock = state_dir() / "rfb.sock" return sock if _launcher_pid() is not None and sock.exists() else None def geometry() -> str: from hermes_cli.config import load_config_readonly cfg = load_config_readonly().get("bot_desktop") or {} return str(cfg.get("geometry") or "1440x900") def status(profile: Optional[str] = None) -> DesktopStatus: from tools.bot_desktop import browser as _bd_browser from tools.bot_desktop import resources missing: list[str] = missing_binaries() if is_supported_host() else list(REQUIRED_BINARIES) pid = _launcher_pid() env = published_env() running = pid is not None and bool(env.get("DISPLAY")) mem = resources.memory_info() if is_supported_host() else resources.MemoryInfo(None, None) return DesktopStatus( profile=profile or _profile_name(), supported=is_supported_host(), installed=not missing, missing=missing, running=running, pid=pid, display=env.get("DISPLAY"), socket=str(rfb_socket_path()) if rfb_socket_path() else None, geometry=geometry(), install_command=install_command() if missing else None, browser=_bd_browser.executable() if is_supported_host() else None, # A running screen is never "blocked": the check guards the allocation, not the session. blocker=None if running or missing or not is_supported_host() else resources.memory_blocker(mem), memory_available_mb=mem.available_mb, memory_limit_mb=mem.limit_mb, ) def _profile_name() -> str: try: from hermes_cli.profiles import get_active_profile_name return get_active_profile_name() or "default" except Exception: return "default" # The gate lives in ``resources`` so start() and status() cannot disagree about it. Measured in the # official image: gateway idle 304 MiB, +216 for Xvnc/Xfce, 1073 MiB with one Chromium page. The OOM # killer picks by score, so on a small instance the casualty is the dashboard or the gateway, not the # desktop that caused the pressure. def start(*, wait_seconds: float = 15.0) -> DesktopStatus: """Start this profile's desktop (idempotent). Blocks until the launcher publishes its env file or ``wait_seconds`` pass; raises ``RuntimeError`` naming the blocker. Two locks: the per-profile ``start.lock``, held from the running-check to the launcher's publish so two start() calls for one profile spawn one launcher (the loser sees it running), and the host-wide display-allocation lock, held only until this Xvnc has written ``/tmp/.X-lock`` (a second profile picking the same number before that would fail and its stale-lock cleanup could remove our socket). Holding it for the whole Xfce bring-up serialized every profile's start behind one desktop launch.""" if not is_supported_host(): raise RuntimeError("Bot Desktop runs on Linux gateway hosts only") missing = missing_binaries() if missing: # Three dead ends: an operator told "unprivileged, no sudo" while running as root hunts the wrong bug. need = f"Bot Desktop needs {', '.join(missing)} on the gateway host" if package_manager() is None: raise RuntimeError( f"{need}, and no supported package manager (apt/dnf/pacman) is available to install them. " "Install TigerVNC (Xvnc) and the Xfce core components with this distro's own tooling.") if not installable(): raise RuntimeError( f"{need}, and this host cannot install them: the process is unprivileged and there is no " "sudo. On the published Docker image the packages have to be baked in, so this needs a " "newer image rather than an install.") raise RuntimeError(f"{need}. Install: {install_command()}") sd = state_dir() sd.mkdir(parents=True, exist_ok=True) os.chmod(sd, 0o700) with _flocked(sd / "start.lock"): if _launcher_pid() is not None and published_env().get("DISPLAY"): return status() from tools.bot_desktop import resources floor = resources.min_free_mb() mem = resources.memory_info() if (blocker := resources.memory_blocker(mem, need=floor)) is not None: raise RuntimeError(blocker) if mem.available_mb is not None and mem.available_mb < resources.tight_headroom_mb(floor): logger.warning( "Bot Desktop starting with %d MB available; a browser with a few pages open can use most " "of that.", mem.available_mb) if _launcher_pid() is None: _reap_orphaned_server(sd) _ALLOC_LOCK.parent.mkdir(parents=True, exist_ok=True, mode=0o700) try: return _spawn_and_wait(sd, wait_seconds) except RuntimeError: # _pick_display reuses the recorded number first: left in place after a failed launch (e.g. Xvnc # 'server already running' on it), every retry would pick the same number and the profile wedges. (sd / "display").unlink(missing_ok=True) raise def _spawn_and_wait(sd: Path, wait_seconds: float) -> DesktopStatus: """Caller holds ``start.lock``. Takes ``_ALLOC_LOCK`` itself, from picking the number to Xvnc's claim.""" with contextlib.ExitStack() as alloc: alloc.enter_context(_flocked(_ALLOC_LOCK)) num = _pick_display() (sd / "display").write_text(str(num), encoding="utf-8") env_file = sd / "env" env_file.unlink(missing_ok=True) child_env = {k: v for k, v in os.environ.items() if k not in { "DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS", "SESSION_MANAGER"}} child_env.update({ "HERMES_BD_PROFILE": _profile_name(), "HERMES_BD_DISPLAY_NUM": str(num), "HERMES_BD_SOCKET": str(sd / "rfb.sock"), "HERMES_BD_XAUTH": str(sd / "Xauthority"), "HERMES_BD_ENV_FILE": str(env_file), "HERMES_BD_CONFIG_HOME": str(sd / "xdg"), "HERMES_BD_GEOMETRY": geometry(), }) from tools.bot_desktop.browser import dock_exec_line, dock_launch if (browser := dock_launch()) is not None: # The bare executable (the launcher checks it exists) and the ready-made, spec-quoted Exec= line. child_env["HERMES_BD_BROWSER_EXEC"] = browser[0] child_env["HERMES_BD_BROWSER_EXEC_LINE"] = dock_exec_line(*browser) # Truncated per start: the log is a diagnostic for THIS launch, and nothing rotates it otherwise. log = open(sd / "launcher.log", "wb") # noqa: SIM115 — handed to the child, closed by it proc = subprocess.Popen( # windows-footgun: ok — Linux-only runtime (is_supported_host) ["bash", str(_LAUNCHER)], env=child_env, stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True, close_fds=True) log.close() born = _create_time(proc.pid) (sd / "launcher.pid").write_text(f"{proc.pid} {born if born is not None else 0}", encoding="utf-8") deadline = time.monotonic() + wait_seconds while time.monotonic() < deadline: if _x_lock_pid(num) is not None: alloc.close() # the number is Xvnc's now; other profiles may allocate (idempotent) if proc.poll() is not None: tail = (sd / "launcher.log").read_bytes()[-2000:].decode("utf-8", "replace") raise RuntimeError(f"Bot Desktop launcher exited with {proc.returncode}:\n{tail}") if env_file.exists() and (sd / "rfb.sock").exists(): logger.info("Bot Desktop for profile %s up on :%s", _profile_name(), num) touch_activity() 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 (or the X server a dead one left behind) was signalled.""" if not is_supported_host(): return False sd = state_dir() sd.mkdir(parents=True, exist_ok=True) with _flocked(sd / "start.lock"): return _stop_locked(sd) 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 reaped # The launcher runs in its own session; killing the group takes Xvnc, dbus and Xfce with it. _kill_group_then_wait(pid, pid, grace=5.0) for name in ("launcher.pid", "env", "activity"): (sd / name).unlink(missing_ok=True) return True