"""Bot Desktop runtime: thumbnail and display-number allocation invariants.""" from __future__ import annotations import contextlib import re import threading import time import sys from pathlib import Path import pytest from tools.bot_desktop import runtime, thumbnail @pytest.mark.parametrize("pm", sorted(runtime.PACKAGES)) def test_every_required_binary_maps_to_an_installed_package(pm): """Each binary the launcher execs must come from a package the distro list actually installs; dnf5 refuses the whole transaction on one retired name, so the map is the contract, not the list.""" mapping = runtime.BINARY_PACKAGES[pm] assert set(mapping) == set(runtime.REQUIRED_BINARIES) assert set(mapping.values()) <= set(runtime.PACKAGES[pm]) assert not {"xorg-x11-server-utils", "xorg-x11-utils"} & set(runtime.PACKAGES["dnf"]), "retired on Fedora" def test_the_image_bakes_the_same_apt_packages_the_runtime_would_install() -> None: """The image layer is the only delivery path on a hosted instance, so a package added here but not there stalls the screen with no error until someone presses Start.""" dockerfile = Path(__file__).resolve().parents[2] / "Dockerfile" text = dockerfile.read_text() assert "ARG HERMES_BOT_DESKTOP" in text, "the Bot Screen apt layer is gone from the Dockerfile" body = text.split("ARG HERMES_BOT_DESKTOP", 1)[1].split("--no-install-recommends", 1)[1].split("rm -rf", 1)[0] baked = {tok for tok in re.split(r"[\s\\&]+", body) if tok and not tok.startswith("-")} required = set(runtime.PACKAGES["apt"]) assert required <= baked, f"the image would not install: {sorted(required - baked)}" # apt `chromium` on top of the operator's list: a headed browser for the dock's Browser icon that # does not depend on Playwright's copy being unpacked yet. assert baked - required <= {"chromium"}, f"unexpected extra packages: {sorted(baked - required)}" def test_no_running_screen_returns_none_without_grabbing(monkeypatch): monkeypatch.setattr(runtime, "published_env", lambda: {"DISPLAY": ":99"}) monkeypatch.setattr(runtime, "_launcher_pid", lambda: None) monkeypatch.setitem(sys.modules, "PIL.ImageGrab", None) # an import would now fail loudly assert thumbnail.thumbnail_data_url() is None def test_recycled_pid_is_not_our_launcher(tmp_path, monkeypatch): """launcher.pid names pid + create_time; a live pid born at another time is a stranger (recycled pid) and must read as not running, or stop() would killpg an unrelated session. Legacy single-number files and absurd digit strings are also not running.""" import os monkeypatch.setattr(runtime, "state_dir", lambda: tmp_path) pidfile = tmp_path / "launcher.pid" pidfile.write_text(f"{os.getpid()} 12345.0", encoding="utf-8") # alive, wrong birth assert runtime._launcher_pid() is None pidfile.write_text(str(os.getpid()), encoding="utf-8") # pre-identity format assert runtime._launcher_pid() is None pidfile.write_text("9" * 40 + " 1.0", encoding="utf-8") assert runtime._launcher_pid() is None pidfile.write_text(f"{os.getpid()} {runtime._create_time(os.getpid())}", encoding="utf-8") assert runtime._launcher_pid() == os.getpid() def test_recorded_display_held_by_a_live_server_is_not_reused(tmp_path, monkeypatch): """After profile A stops, B may take A's number; A restarting must pick another rather than unlink B's socket and lock.""" import os monkeypatch.setattr(runtime, "state_dir", lambda: tmp_path) (tmp_path / "display").write_text("37", encoding="utf-8") live = {37: os.getpid()} # :37 is owned by a running server (this very process stands in for it) monkeypatch.setattr(runtime, "_display_in_use", lambda num: num in live) monkeypatch.setattr(runtime, "_ALLOC_LOCK", tmp_path / "alloc.lock") assert runtime._allocate_display() != 37 live.clear() assert runtime._allocate_display() == 37, "a free recorded number is reclaimed" def test_live_server_without_its_lock_file_still_owns_the_display(tmp_path, monkeypatch): """Regression for #109941: a /tmp reaper removes ``.X-lock`` while Xvnc keeps running. The server's abstract socket ``@/tmp/.X11-unix/X`` stays bound for its whole life, so that is the liveness the allocator must honour — handing the number out makes the next launcher die with 'server already running'.""" monkeypatch.setattr(runtime, "_X_LOCK_DIR", tmp_path / "xlocks") # no lock file for anyone (tmp_path / "xlocks").mkdir() table = tmp_path / "unix" table.write_text( "Num RefCount Protocol Flags Type St Inode Path\n" "0000000000000000: 00000002 00000000 00010000 0001 01 22242 @/tmp/.X11-unix/X20\n" "0000000000000000: 00000002 00000000 00010000 0001 01 22243 /tmp/.X11-unix/X21\n" "0000000000000000: 00000003 00000000 00000000 0001 03 22244 @/tmp/.X11-unix/X200\n", encoding="utf-8") monkeypatch.setattr(runtime, "_X_UNIX_TABLE", table) assert runtime._display_in_use(20) is True assert runtime._display_in_use(21) is True assert runtime._display_in_use(22) is False # :200 is not :22 — no prefix matching monkeypatch.setattr(runtime, "_X_UNIX_TABLE", tmp_path / "missing") assert runtime._display_in_use(20) is False, "no table (non-Linux procfs) falls back to the lock alone" _FAKE_LAUNCHER = """#!/usr/bin/env bash # Stands in for launcher.sh + Xvnc: the X lock appears only after a delay (the TOCTOU window), then the # env file + socket are published; stays alive until killed like the real supervisor. : > "$HERMES_BD_XLOCK_DIR/spawned.$$" sleep 0.4 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" sleep 30 """ # One start() per process: state_dir() is HERMES_HOME-scoped and process-global, so two profiles need two # interpreters — which is also how two gateway profiles race on a real host. _DRIVER = """ import json, os, sys from pathlib import Path sys.path.insert(0, {repo!r}) from tools.bot_desktop import runtime scratch = Path({scratch!r}) runtime._LAUNCHER = scratch / "launcher.sh" runtime._X_LOCK_DIR = scratch / "xlocks" runtime._ALLOC_LOCK = scratch / "alloc.lock" runtime.missing_binaries = lambda: [] runtime.geometry = lambda: "800x600" os.environ["HERMES_BD_XLOCK_DIR"] = str(scratch / "xlocks") try: st = runtime.start(wait_seconds=10) print(json.dumps({{"display": st.display, "pid": st.pid}}), flush=True) except Exception as exc: print(json.dumps({{"error": str(exc)}}), flush=True) sys.stdin.readline() # the test releases us once every driver has reported; we own the launcher, we stop it runtime.stop() """ @pytest.fixture def start_in_fresh_process(tmp_path): import os import subprocess (tmp_path / "launcher.sh").write_text(_FAKE_LAUNCHER, encoding="utf-8") (tmp_path / "xlocks").mkdir() repo = str(Path(__file__).resolve().parents[2]) procs: list[subprocess.Popen] = [] def launch(home: Path) -> subprocess.Popen: env = {**os.environ, "HERMES_HOME": str(home)} proc = subprocess.Popen([sys.executable, "-c", _DRIVER.format(repo=repo, scratch=str(tmp_path))], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) procs.append(proc) return proc yield launch for proc in procs: with contextlib.suppress(OSError): proc.communicate("go\n", timeout=20) proc.kill() def _collect(procs): import json out = [json.loads(p.stdout.readline()) for p in procs] # every driver holds its launcher until released assert all("error" not in o for o in out), out return out @pytest.mark.platforms("linux") def test_concurrent_cold_starts_of_two_profiles_get_distinct_displays(tmp_path, start_in_fresh_process): """The allocation lock must outlive the pick: Xvnc writes /tmp/.X-lock well after start() chose n, so a second profile starting in that window used to pick the same n (and its launcher's stale-lock cleanup could then unlink the winner's socket).""" out = _collect([start_in_fresh_process(tmp_path / "a"), start_in_fresh_process(tmp_path / "b")]) assert len({o["display"] for o in out}) == 2, out @pytest.mark.platforms("linux") def test_concurrent_starts_of_one_profile_spawn_one_launcher(tmp_path, start_in_fresh_process): """Two start() calls for one profile spawn ONE launcher; the second used to spawn its own, overwrite launcher.pid and orphan the first (both callers then reported the last-written pid).""" 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, "_X_UNIX_TABLE", tmp_path / "unix") # the host's real X servers stay out of the band 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(encoding="utf-8-sig")), 9) @pytest.mark.platforms("linux") @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(encoding="utf-8-sig")) os.kill(first.pid, signal.SIGKILL) # windows-footgun: ok — Linux-only test requires an uncatchable launcher death 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 @pytest.mark.platforms("linux") @pytest.mark.live_system_guard_bypass # the orphan is reparented to init: signalling it is the point def test_orphaned_x_server_is_found_by_its_socket_when_the_lock_file_is_gone(tmp_path, monkeypatch): """Case B of #109941: the launcher was SIGKILLed AND a /tmp reaper removed ``.X-lock`` (or the failed launch dropped ``display``). The lock was the reaper's only handle, so the live Xvnc leaked forever and each restart allocated a new number beside it. The socket path on its command line names it too.""" import os import subprocess sd = tmp_path / "bot-desktop" sd.mkdir() (sd / "rfb.sock").touch() (sd / "launcher.pid").write_text("1 0.0", encoding="utf-8") # a dead launcher, not our process group monkeypatch.setattr(runtime, "_X_LOCK_DIR", tmp_path / "xlocks") # no lock file at all (tmp_path / "xlocks").mkdir() # argv[0] names the fake Xvnc and argv carries our socket path, exactly what launcher.sh's Xvnc shows. # Python is the portable long-lived payload here: Nix's multicall coreutils dispatches from argv[0], so # executing `tail` under the Xvnc spelling exits immediately instead of establishing the precondition. orphan = subprocess.Popen([str(tmp_path / "Xvnc"), "-c", "import time; time.sleep(60)", str(sd / "rfb.sock")], executable=sys.executable, start_new_session=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) try: import psutil def orphan_command_is_published() -> bool: try: command = psutil.Process(orphan.pid).cmdline() except psutil.Error: return False return bool(command and "Xvnc" in Path(command[0]).name and str(sd / "rfb.sock") in command) assert _wait_until(orphan_command_is_published), "establish the psutil discovery precondition" assert runtime._reap_orphaned_server(sd) is True assert _wait_until(lambda: _gone(orphan.pid)), "the lock-less orphan must be reaped, not leaked" assert not (sd / "rfb.sock").exists() finally: with contextlib.suppress(ProcessLookupError): os.kill(orphan.pid, 9) orphan.wait() _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.platforms("linux") @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(encoding="utf-8-sig"))), "the launch's X server must die with its launcher" _DYING_LAUNCHER = """#!/usr/bin/env bash # Xvnc refusing the number ('server already running'): the launcher exits non-zero at once. exit 1 """ @pytest.mark.platforms("linux") def test_failed_start_does_not_pin_the_profile_to_the_number_that_failed(in_process_runtime): """Regression for #109941: after a launcher failure the recorded ``display`` kept naming the number, and ``_pick_display`` reuses the recorded number first — so every retry picked the same occupied display and the profile wedged. A failed start forgets its number; the next attempt allocates afresh.""" scratch = in_process_runtime (scratch / "launcher.sh").write_text(_DYING_LAUNCHER, encoding="utf-8") sd = runtime.state_dir() with pytest.raises(RuntimeError, match="launcher exited"): runtime.start(wait_seconds=5) assert not (sd / "display").exists(), "a number that just failed must not be recorded for reuse" @pytest.mark.platforms("linux") def test_allocation_lock_is_released_once_xvnc_claims_the_number(in_process_runtime): """The host-wide allocation lock exists for the pick→X-lock window only. Holding it for the whole Xfce bring-up serialized every profile's start behind one desktop launch (and a hung launcher blocked them all for the full timeout): once /tmp/.X-lock exists the number is Xvnc's and the lock must be free.""" import fcntl scratch = in_process_runtime # X lock at once, env file only much later: the lock must be free in between. (scratch / "launcher.sh").write_text( '#!/usr/bin/env bash\necho $$ > "$HERMES_BD_XLOCK_DIR/.X${HERMES_BD_DISPLAY_NUM}-lock"\n' 'sleep 1.5\n: > "$HERMES_BD_SOCKET"\nprintf \'DISPLAY=:%s\\n\' "$HERMES_BD_DISPLAY_NUM" > "$HERMES_BD_ENV_FILE"\nsleep 30\n', encoding="utf-8") seen: dict = {} def probe(): deadline = time.monotonic() + 3 while time.monotonic() < deadline: if list((scratch / "xlocks").glob(".X*-lock")): time.sleep(0.2) # let start() notice the claim with open(scratch / "alloc.lock", "a+", encoding="utf-8") as fh: try: fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) seen["free"] = True fcntl.flock(fh.fileno(), fcntl.LOCK_UN) except OSError: seen["free"] = False return time.sleep(0.05) t = threading.Thread(target=probe) t.start() st = runtime.start(wait_seconds=10) t.join() assert st.running assert seen.get("free") is True, "allocation lock still held after Xvnc wrote its X lock" def _startable_host(monkeypatch, tmp_path, *, running=False): """A Linux host with the packages present, so only the check under test can block a start.""" from tools.bot_desktop import resources monkeypatch.setattr(runtime, "is_supported_host", lambda: True) monkeypatch.setattr(runtime, "missing_binaries", lambda: []) monkeypatch.setattr(runtime, "state_dir", lambda: tmp_path / "bd") monkeypatch.setattr(runtime, "_launcher_pid", lambda: 4242 if running else None) monkeypatch.setattr(runtime, "published_env", lambda: {"DISPLAY": ":7"} if running else {}) monkeypatch.setattr(runtime, "_reap_orphaned_server", lambda sd: None) monkeypatch.setattr(resources, "min_free_mb", lambda: 1536) monkeypatch.setattr(resources, "memory_info", lambda: resources.MemoryInfo(available_mb=400, limit_mb=4096)) spawned: list = [] monkeypatch.setattr(runtime, "_spawn_and_wait", lambda *a, **k: spawned.append(a)) return spawned def test_a_running_desktop_is_never_refused_for_the_memory_it_is_using(tmp_path, monkeypatch): """The gate guards the allocation, not the session: a running desktop is itself what consumes the memory, so checking before the running-check made Start fail on a healthy screen.""" _startable_host(monkeypatch, tmp_path, running=True) runtime.start() # returns status(); must not raise about headroom def test_a_root_host_without_a_package_manager_is_told_the_truth(tmp_path, monkeypatch): """Root with no package manager: installable() is False for a reason unrelated to privilege.""" _startable_host(monkeypatch, tmp_path) monkeypatch.setattr(runtime, "missing_binaries", lambda: ["Xvnc"]) monkeypatch.setattr(runtime, "package_manager", lambda: None) monkeypatch.setattr(runtime, "is_root", lambda: True) assert runtime.installable() is False with pytest.raises(RuntimeError) as excinfo: runtime.start() message = str(excinfo.value) assert "package manager" in message assert "unprivileged" not in message and "sudo" not in message, f"wrong diagnosis: {message}" def test_an_unprivileged_host_is_pointed_at_the_image(tmp_path, monkeypatch): """The published image: a package manager exists but there is no way to reach root.""" _startable_host(monkeypatch, tmp_path) monkeypatch.setattr(runtime, "missing_binaries", lambda: ["Xvnc"]) monkeypatch.setattr(runtime, "package_manager", lambda: "apt") monkeypatch.setattr(runtime, "is_root", lambda: False) monkeypatch.setattr(runtime.shutil, "which", lambda name: None if name == "sudo" else "/usr/bin/" + name) assert runtime.installable() is False with pytest.raises(RuntimeError, match="baked in"): runtime.start() def test_a_host_that_can_install_gets_the_command(tmp_path, monkeypatch): """The branch that used to be unreachable behind an `or` fallback.""" _startable_host(monkeypatch, tmp_path) monkeypatch.setattr(runtime, "missing_binaries", lambda: ["Xvnc"]) monkeypatch.setattr(runtime, "package_manager", lambda: "apt") monkeypatch.setattr(runtime, "is_root", lambda: True) with pytest.raises(RuntimeError, match="tigervnc-standalone-server"): runtime.start() def test_a_tight_but_sufficient_start_is_logged(tmp_path, monkeypatch, caplog): """Above the floor but below the derived threshold the start proceeds and says so. It is the only signal an operator gets that a screen came up with no room for the browser that is the point of it, so it has to actually fire rather than merely be computable.""" from tools.bot_desktop import resources spawned = _startable_host(monkeypatch, tmp_path) monkeypatch.setattr(resources, "min_free_mb", lambda: 1536) # threshold -> 2048 monkeypatch.setattr(resources, "memory_info", lambda: resources.MemoryInfo(available_mb=1800, limit_mb=2048)) with caplog.at_level("WARNING", logger="tools.bot_desktop.runtime"): runtime.start() assert spawned, "1800 MB clears the 1536 MB floor, so the screen still starts" logged = [r.getMessage() for r in caplog.records] assert any("1800 MB available" in m for m in logged), f"no tight-headroom warning in {logged}" def test_a_comfortable_start_is_not_logged(tmp_path, monkeypatch, caplog): """And it stays quiet with real headroom, or it would fire on every start and mean nothing.""" from tools.bot_desktop import resources _startable_host(monkeypatch, tmp_path) monkeypatch.setattr(resources, "min_free_mb", lambda: 1536) monkeypatch.setattr(resources, "memory_info", lambda: resources.MemoryInfo(available_mb=7210, limit_mb=8182)) with caplog.at_level("WARNING", logger="tools.bot_desktop.runtime"): runtime.start() assert not [m for m in (r.getMessage() for r in caplog.records) if "available" in m]