From 03fcf23f530c2b117c198314b1d504677a340e73 Mon Sep 17 00:00:00 2001 From: Tim Richardson Date: Tue, 15 Sep 2026 13:16:07 +1000 Subject: [PATCH] fix: the headless session never had an AT-SPI registry, so ui_* tools had nothing to talk to A private dbus-daemon cannot activate at-spi2-registryd: the a11y broker routes the start through org.freedesktop.systemd1, and on a bus with no systemd that name is the stub that answers /bin/false. So the headless a11y bus existed but no registry ever owned org.a11y.atspi.Registry, and ui_tree/ui_find failed with 'no application named gnome-shell' while the user's real session, whose bus has real systemd behind it, worked. headless.py now starts registryd itself, BEFORE the shell: order matters, because the shell's atk-bridge stops retrying registration when no registry answers (measured: registry-after-shell = 16/18 self-test, registry-before-shell = 18/18). Unit tests for the spawn/liveness/stop paths run anywhere; the live proof is tests/test_headless_atspi.py, which fails 2/3 checks on main and passes 3/3 here, and a cold headless self-test at 18/18. --- deskwright/headless.py | 179 ++++++++++++++++++++++++++++++++- docs/field-notes.md | 40 ++++++++ tests/test_headless.py | 156 ++++++++++++++++++++++++++++- tests/test_headless_atspi.py | 185 +++++++++++++++++++++++++++++++++++ 4 files changed, 557 insertions(+), 3 deletions(-) create mode 100755 tests/test_headless_atspi.py diff --git a/deskwright/headless.py b/deskwright/headless.py index 5a8729d..16bcc07 100644 --- a/deskwright/headless.py +++ b/deskwright/headless.py @@ -76,6 +76,16 @@ DISPLAY_PREFIX = "wayland-deskwright" RUNTIME_PREFIX = "deskwright-headless" +# The AT-SPI registry, which every ui_* tool needs. Normally the session's +# a11y bus launcher starts it; on the PRIVATE bus it never does (see +# _ensure_atspi_registry for the measured chain of causes). +REGISTRY_NAME = "org.a11y.atspi.Registry" +# /proc//comm truncates to 15 chars (TASK_COMM_LEN): "at-spi2-registryd" +# reads back as "at-spi2-registr". Measured 2026-09-15, at-spi2-core 2.60.4. +# pid-liveness checks must use the truncated form or they never match. +REGISTRYD_COMM = "at-spi2-registr" +REGISTRY_SERVICE = "/usr/share/dbus-1/accessibility-services/org.a11y.atspi.Registry.service" + # The spike's shell reached "extension answering" well inside 15 s on this # machine; the margin covers a loaded box without making a real failure slow # to report. @@ -256,6 +266,7 @@ def status(name: str | None = None) -> dict[str, Any]: "bus_reachable": bus_ok, "extension_answering": ext_ok, **{k: state[k] for k in ("bus_address", "wayland_display", "size", "runtime_dir", "home", "shell_pid", "dbus_pid", + "registry_pid", "started_at", "log") if k in state}, } @@ -414,6 +425,154 @@ def start(size: str = DEFAULT_SIZE, display: str | None = None, return _start_locked(name, size, display, home) +def _registry_alive(address: str) -> bool: + """Is org.a11y.atspi.Registry owned on the a11y bus behind `address`? + + The registry is a name on the ACCESSIBILITY bus, not the session bus, + so this resolves the a11y socket the way every real AT-SPI client does + (org.a11y.Bus.GetAddress on the session bus) and asks that bus for the + name owner. No socket means the a11y launcher never started either, + which is also "not alive" for the caller's purposes. + """ + socket_path = _a11y_socket_path(address) + if socket_path is None: + return False + try: + proc = subprocess.run( + ["gdbus", "call", "--address", f"unix:path={socket_path}", + "--dest", "org.freedesktop.DBus", "--object-path", "/org/freedesktop/DBus", + "--method", "org.freedesktop.DBus.GetNameOwner", + REGISTRY_NAME], + capture_output=True, timeout=5) + return proc.returncode == 0 + except (subprocess.TimeoutExpired, OSError): + return False + + +def _a11y_socket_path(address: str) -> str | None: + """The a11y bus socket for the session bus at `address`. + + The at-spi bus launcher binds it under the session's XDG_RUNTIME_DIR + (`/at-spi/bus`). This function asks the session bus for the + address instead of guessing a path, because the launcher writes it + there: org.a11y.Bus owns the socket address on the session bus. + """ + try: + out = subprocess.run( + ["gdbus", "call", "--session", "--dest", "org.a11y.Bus", + "--object-path", "/org/a11y/bus", "--method", + "org.a11y.Bus.GetAddress"], + env=dict(os.environ, DBUS_SESSION_BUS_ADDRESS=address), + capture_output=True, text=True, timeout=5) + if out.returncode != 0 or not out.stdout.strip(): + return None + # ('unix:path=/run/user/1000/deskwright-headless/at-spi/bus',) + address_str = out.stdout.strip().strip("()").split(",")[0].strip().strip("'\"") + if address_str.startswith("unix:path="): + return address_str[len("unix:path="):] + return None + except (subprocess.TimeoutExpired, OSError): + # An unreachable org.a11y.Bus on the session bus means the a11y + # launcher itself never started; _registry_alive's caller treats + # that as "not alive", which is the safe answer. + return None + + +def _ensure_atspi_registry(address: str, log_path: str) -> str | None: + """Start at-spi2-registryd on the private session, or say why not. + + THE BUG THIS EXISTS FOR (measured 2026-09-15, Ubuntu 26.04, at-spi2-core + 2.60.4, GNOME Shell 50.1, systemd 259): + + The private session's at-spi-bus-launcher starts its a11y broker fine. + The broker then tries to start registryd by asking the bus to activate + org.freedesktop.systemd1, and Ubuntu's session-service dir maps that + name to `Exec=/bin/false` (upstream systemd ships that stub so a + systemd-less bus refuses politely). On the USER's real session bus the + name resolves to the real systemd and registryd starts; on a private + dbus-daemon --session it never can. Result: the headless session's a11y + bus exists, its socket answers, but org.a11y.atspi.Registry is owned by + nobody, and every ui_* tool fails with app_not_on_bus / + "no application named 'gnome-shell' on the AT-SPI bus". + + The same chain fails on any distro whose at-spi2-core is >= 2.59.0, + which switched the launcher to dbus-broker by default: dbus-broker's + launcher is the one that proxies service activation through systemd. + The Ubuntu 25.10 stack (at-spi 2.56, dbus 1.14 x) does not hit it. + + THE FIX: start registryd directly, pointed at the private session bus. + registryd resolves the a11y socket itself by asking the session bus + (measured 2026-09-15: starts and owns org.a11y.atspi.Registry with only + DBUS_SESSION_BUS_ADDRESS set, no AT_SPI_BUS needed), and + --use-gnome-session makes it exit when its bus dies, so it cannot + outlive the session. + + Returns the pid (as str) for the state file when THIS start spawned and + verified a registryd, else None (already alive, no binary, or it died + young -- the reason is logged either way). A None never blocks the + session: the pre-fix behaviour is exactly "no registry", degraded but + running, and old state files simply have no registry_pid at all. + + `log_path` (not an open handle: _start_locked's log is opened "ab" for + the subprocesses, and print(str) into a binary handle is a TypeError, + found the embarrassing way 2026-09-15) is the session log every other + bring-up message goes to. + """ + def _log(message: str) -> None: + with open(log_path, "a") as f: + print(f"deskwright: {message}", file=f, flush=True) + + if _registry_alive(address): + _log(f"{REGISTRY_NAME} already owned on the headless a11y bus; " + f"not starting another") + return None + binary = shutil.which("at-spi2-registryd") or _registryd_path() + if binary is None: + _log("no at-spi2-registryd on this machine; ui_* tools will be " + f"unavailable on the headless session (searched PATH and " + f"{REGISTRY_SERVICE})") + return None + _log(f"starting {binary} for the headless a11y bus (the private bus " + f"cannot activate it; see the comment at _ensure_atspi_registry)") + # One handle for both streams, closed as soon as the spawn returns; the + # child keeps its own dup of the fd. + with open(log_path, "ab") as child_log: + registryd = subprocess.Popen( + [binary, "--use-gnome-session"], + env=dict(os.environ, DBUS_SESSION_BUS_ADDRESS=address), + stdout=child_log, stderr=child_log, start_new_session=True) + # Wait for the name to be owned, the same wait-for-readiness shape the + # shell start uses. 10s: registryd takes well under 1s on this machine; + # the margin is for a cold cache on a loaded box. + deadline = time.monotonic() + 10.0 + while time.monotonic() < deadline: + if _registry_alive(address): + _log(f"{REGISTRY_NAME} owned by pid {registryd.pid} " + f"after {time.monotonic() - (deadline - 10.0):.1f}s") + return str(registryd.pid) + if registryd.poll() is not None: + break + time.sleep(0.2) + _log(f"registryd exited rc={registryd.returncode} before owning " + f"{REGISTRY_NAME}") + return None + + +def _registryd_path() -> str | None: + """Locate at-spi2-registryd without a PATH entry, from the D-Bus service + file the distro shipped, the same place `deskwright-setup --check` reads + package facts from.""" + try: + with open(REGISTRY_SERVICE) as f: + for line in f: + line = line.strip() + if line.startswith("Exec="): + return line[len("Exec="):].split()[0] + except OSError: + return None + return None + + def _start_locked(name: str, size: str, display: str, home: str | None = None) -> dict[str, Any]: _check_capacity(name) @@ -487,6 +646,15 @@ def _start_locked(name: str, size: str, display: str, # empty; seeding later is too late, because gnome-shell reads # enabled-extensions at startup. _seed_home(home, env) + # The a11y registry must be up BEFORE the shell, not after: the shell's + # atk-bridge registers during startup, and how long it keeps retrying + # when no registry answers is not contract we can rely on. Measured + # both ways 2026-09-15: with the registry appearing ~20 s after the + # shell, self-test ui_tree failed with gnome-shell absent from the bus + # while apps started later (portal-gtk, ibus) were on it; a registry + # started before the shell gave 18/18. This also matches the primary + # session, where the registry is up before any app starts. + registry_pid = _ensure_atspi_registry(address, log_path) # The headless shell CREATES a display; it must not attach to the user's. env.pop("WAYLAND_DISPLAY", None) env.pop("DISPLAY", None) @@ -531,6 +699,7 @@ def _start_locked(name: str, size: str, display: str, "runtime_dir": runtime_dir, "home": os.path.abspath(os.path.expanduser(home)) if home else None, "shell_pid": shell.pid, "dbus_pid": dbus.pid, + "registry_pid": registry_pid, "started_at": time.time(), "log": log_path, } with open(_state_file(name), "w") as f: @@ -556,8 +725,14 @@ def stop(name: str | None = None) -> dict[str, Any]: return {"stopped": False, "name": name, "detail": f"no headless session recorded for {name!r}"} ended = [] - for key, comm in (("shell_pid", "gnome-shell"), ("dbus_pid", "dbus-daemon")): - pid = int(state.get(key, -1)) + # registry_pid may be None (registry already alive, or not startable + # here); int(None) is a crash, so the default has to cover it. + for key, comm in (("shell_pid", "gnome-shell"), ("dbus_pid", "dbus-daemon"), + ("registry_pid", REGISTRYD_COMM)): + raw = state.get(key) + if raw is None: + continue + pid = int(raw) if not _pid_is(pid, comm): continue os.kill(pid, signal.SIGTERM) diff --git a/docs/field-notes.md b/docs/field-notes.md index 59eaac2..d189ede 100644 --- a/docs/field-notes.md +++ b/docs/field-notes.md @@ -348,3 +348,43 @@ The new restricted window_layout tool inherits that same implementation. This latency issue was left unchanged in both conditions to isolate scoped approval changes. A follow-up should validate the requested geometry and state before waiting, while retaining verification for actual moves and asynchronous mapping. + + +## The headless session never had an AT-SPI registry (2026-09-15) + +The headless self-test failing `ui_tree`/`ui_find` with "no application named +'gnome-shell' on the AT-SPI bus" was not a flaky app. The headless session's +a11y bus had no registry at all, and never had. + +The chain, read out of the session's own log (`~/.local/state/deskwright/ +headless-shell*.log`): the private dbus-daemon activates `org.a11y.Bus` fine, +the a11y broker then asks the session bus to activate +`org.freedesktop.systemd1` to start `at-spi2-registryd`, and on a bus with no +systemd behind it that name is served by the stub systemd ships for +systemd-less buses, `Exec=/bin/false`, which exits 1: + + Activating service name='org.freedesktop.systemd1' ... failed: + Process org.freedesktop.systemd1 exited with status 1 + +The user's real session never sees this, because its bus has the real systemd +behind the name. That asymmetry is why this looked like "apps expose stunted +trees" (the usual toolkit-accessibility explanation) instead of "no registry". + +Two measurements worth keeping: + +* at-spi2-core 2.59.0 switched the a11y launcher to dbus-broker by default, + and the dbus-broker launcher is the one that routes activation through + systemd. GNOME 49 and older stacks do not hit this; anything on 2.59+ + should, whatever the distro. (Only measured on Ubuntu 26.04, at-spi 2.60.4.) +* ORDER matters. Starting registryd before the shell gives 18/18. Starting it + after the shell answers gives 16/18 anyway, because gnome-shell's + atk-bridge gives up registering after a while and does not come back: apps + started later (portal-gtk, ibus) were on the bus while gnome-shell was not, + in the same session. The primary session always has its registry before any + app starts, and the headless one now does too. + +The fix spawns registryd directly against the private bus +(deskwright/headless.py, `_ensure_atspi_registry`). It resolves the a11y +socket itself from `org.a11y.Bus.GetAddress`, so `DBUS_SESSION_BUS_ADDRESS` +is the only variable it needs, and `--use-gnome-session` makes it exit when +the bus dies, so it cannot outlive the session. diff --git a/tests/test_headless.py b/tests/test_headless.py index 75c2925..3574733 100644 --- a/tests/test_headless.py +++ b/tests/test_headless.py @@ -293,8 +293,162 @@ def test_pin_env_sends_launched_apps_to_the_session_home(tmp_path): def test_a_session_without_a_home_leaves_the_environment_alone(tmp_path): - env: dict = {} + env: dict[str, str] = {} headless.pin_env({"bus_address": "unix:path=/tmp/x", "wayland_display": "wayland-deskwright-demo", "runtime_dir": "/run/user/1000/x", "name": "demo"}, env) assert "HOME" not in env + + +# ---- the headless a11y registry (2026-09-15) ------------------------------- +# +# A private dbus-daemon cannot activate at-spi2-registryd: the a11y broker +# proxies activation through org.freedesktop.systemd1, which answers +# /bin/false on a bus with no systemd behind it. So headless.py now spawns +# registryd itself. These tests hold that code to its measured facts without +# needing a desktop; tests/test_headless_atspi.py proves it against a real +# headless session. + +def test_registryd_comm_is_the_truncated_proc_name(): + """_pid_is reads /proc//comm, which the kernel truncates to 15 + chars (TASK_COMM_LEN). The untruncated string never matches, and a + wrong comm means a registryd that is never believed alive nor cleaned + up. Measured 2026-09-15, at-spi2-core 2.60.4.""" + assert "at-spi2-registryd"[:15] == headless.REGISTRYD_COMM + assert len(headless.REGISTRYD_COMM) == 15 + + +class _FakeRegistryd: + pid = 4242 + returncode = None + + def poll(self) -> int | None: + return None + + +def _wire_fake_registryd(monkeypatch, alive_after: int = 1) -> dict: + """Point _ensure_atspi_registry at a fake spawn. `alive_after` is the + poll count after which the registry pretends to be up, so the happy + path exercises the wait loop exactly once.""" + seen: dict = {} + polls = {"n": 0} + + def fake_popen(argv, **kw): + seen["argv"] = argv + seen["env"] = kw["env"] + return _FakeRegistryd() + + def fake_alive(address): + polls["n"] += 1 + return polls["n"] > alive_after + + monkeypatch.setattr(headless.subprocess, "Popen", fake_popen) + monkeypatch.setattr(headless, "_registry_alive", fake_alive) + monkeypatch.setattr(headless.shutil, "which", + lambda b: "/usr/libexec/at-spi2-registryd") + return seen + + +def test_ensure_atspi_registry_spawns_registryd_on_the_private_bus(monkeypatch, tmp_path): + seen = _wire_fake_registryd(monkeypatch) + log = tmp_path / "headless.log" + log.write_text("") + pid = headless._ensure_atspi_registry("unix:path=/tmp/private-bus", str(log)) + assert pid == "4242" + assert seen["argv"] == ["/usr/libexec/at-spi2-registryd", + "--use-gnome-session"] + # The one environment fact that makes the fix work: registryd resolves + # the a11y socket itself by asking the session bus, so pointing it at + # the private bus is all it needs (measured 2026-09-15: no AT_SPI_BUS + # required). + assert seen["env"]["DBUS_SESSION_BUS_ADDRESS"] == "unix:path=/tmp/private-bus" + # and the bring-up line lands in the session log, not stdout + assert "starting" in log.read_text() + + +def test_ensure_atspi_registry_does_not_double_start(monkeypatch, tmp_path): + monkeypatch.setattr(headless, "_registry_alive", lambda a: True) + + def boom(*a, **k): + pytest.fail("spawned a registryd when one was already answering") + + monkeypatch.setattr(headless.subprocess, "Popen", boom) + assert headless._ensure_atspi_registry("unix:path=/tmp/x", + str(tmp_path / "l.log")) is None + + +def test_ensure_atspi_registry_degrades_when_there_is_no_binary(monkeypatch, tmp_path): + monkeypatch.setattr(headless, "_registry_alive", lambda a: False) + monkeypatch.setattr(headless.shutil, "which", lambda b: None) + monkeypatch.setattr(headless, "_registryd_path", lambda: None) + log = tmp_path / "l.log" + # None, not a crash: a machine without registryd gets the pre-fix + # behaviour (session works, ui_* tools do not) and a log line saying so. + assert headless._ensure_atspi_registry("unix:path=/tmp/x", str(log)) is None + assert "no at-spi2-registryd" in log.read_text() + + +def test_ensure_atspi_registry_reports_a_registryd_that_dies_young(monkeypatch, tmp_path): + seen = _wire_fake_registryd(monkeypatch, alive_after=99) # never "up" + + def dead_poll(self): + self.returncode = 1 + return 1 + + monkeypatch.setattr(_FakeRegistryd, "poll", dead_poll) + log = tmp_path / "l.log" + assert headless._ensure_atspi_registry("unix:path=/tmp/x", str(log)) is None + assert "exited rc=1" in log.read_text() + assert seen["argv"][0].endswith("at-spi2-registryd") + + +def test_registryd_path_is_read_from_the_distro_service_file(tmp_path): + service = tmp_path / "org.a11y.atspi.Registry.service" + service.write_text("[D-Bus Service]\nName=org.a11y.atspi.Registry\n" + "Exec=/usr/libexec/at-spi2-registryd --use-gnome-session\n") + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr(headless, "REGISTRY_SERVICE", str(service)) + try: + assert headless._registryd_path() == "/usr/libexec/at-spi2-registryd" + finally: + monkeypatch.undo() + + +def test_stop_ends_the_registryd_it_started(tmp_path, monkeypatch): + import signal + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path)) + state_dir = tmp_path / "deskwright" + state_dir.mkdir() + state = {**_dead_state(), "registry_pid": 4242} + (state_dir / "headless.json").write_text(json.dumps(state)) + killed: list[tuple[int, int]] = [] + + def fake_pid_is(pid, comm): + # dies on cue when killed, so stop() does not wait out its timeout + return comm == headless.REGISTRYD_COMM and (pid, signal.SIGTERM) not in killed + + monkeypatch.setattr(headless, "_pid_is", fake_pid_is) + monkeypatch.setattr(headless.os, "kill", + lambda pid, sig: killed.append((pid, int(sig)))) + report = headless.stop() + assert report["stopped"] is True + assert killed == [(4242, signal.SIGTERM)] + + +def test_stop_tolerates_a_session_with_no_registry_pid(tmp_path, monkeypatch): + """Sessions started before the fix (and ones where the registry could + not be started) have no registry_pid, or an explicit None. Stopping + them must not crash on the way to ending the shell and the bus.""" + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path)) + state_dir = tmp_path / "deskwright" + state_dir.mkdir() + for raw in (None, "4242"): + (state_dir / "headless.json").write_text( + json.dumps({**_dead_state(), "registry_pid": raw})) + killed: list[tuple[int, int]] = [] + monkeypatch.setattr(headless, "_pid_is", lambda pid, comm: False) + monkeypatch.setattr(headless.os, "kill", + lambda pid, sig, k=killed: k.append((pid, int(sig)))) + report = headless.stop() + assert report["stopped"] is True + assert killed == [] # nothing alive to kill, no crash either diff --git a/tests/test_headless_atspi.py b/tests/test_headless_atspi.py new file mode 100755 index 0000000..4f57819 --- /dev/null +++ b/tests/test_headless_atspi.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""The a11y registry on a headless session: is one actually running? + +THE BUG (found 2026-09-15, Ubuntu 26.04 / GNOME Shell 50.1 / at-spi2-core +2.60.4): a headless session comes up and its extension answers, but +org.a11y.atspi.Registry is owned by nobody on the headless a11y bus, so +ui_apps says "0 apps", ui_tree and ui_find fail with "no application named +'gnome-shell' on the AT-SPI bus", and the headless self-test fails 2/18. + +The chain, from the session's own log: + + 1. The headless session runs a PRIVATE dbus-daemon, which has no systemd + behind it. That is the point of it (nothing can reach the user's + desktop), but it also means nothing can be activated through systemd. + 2. The session's at-spi-bus-launcher starts the a11y broker fine, then + tries to start at-spi2-registryd by asking the broker to activate + org.freedesktop.systemd1 -- and on a bus with no systemd that name is + served by the stub systemd ships for systemd-less buses, + Exec=/bin/false, which exits 1. The dbus-daemon log says exactly this, + once per retry: + Activating service name='org.freedesktop.systemd1' ... failed: + Process org.freedesktop.systemd1 exited with status 1 + 3. So the registry never starts, and every ui_* tool on the headless + session fails. + +The user's real session never sees this: its bus has the real systemd +behind org.freedesktop.systemd1, activation works, the registry runs. + +This script proves the bug without guessing, by asking the headless +session's a11y bus itself. It needs a live GNOME Wayland login to create +the headless session (a second gnome-shell compositing a virtual monitor), +and it cleans up after itself unless --keep is passed. + +Exit code 0 = registry is up on the headless session. Anything else = the +bug is present (printout says which step failed). On a checkout of main +this fails; with headless.py spawning registryd itself it passes. + + ./tests/test_headless_atspi.py + ./tests/test_headless_atspi.py --name repro # a session of your own + ./tests/test_headless_atspi.py --keep # leave the session up +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent +sys.path.insert(0, str(ROOT)) + +from deskwright import headless + +passed = failed = 0 + +def check(label: str, ok: bool, detail: str = "") -> None: + global passed, failed + if ok: + passed += 1 + print(f"PASS {label:44} {detail}") + else: + failed += 1 + print(f"FAIL {label:44} {detail}") + +# The two probes below are deliberately inline gdbus calls rather than +# helpers from headless.py: this script has to run UNCHANGED on a checkout +# of main, where the bug lives, so it cannot depend on any fix-branch code. + + +def a11y_socket(session_bus: str) -> str | None: + """Resolve the session's a11y bus socket the way every AT-SPI client + does: ask org.a11y.Bus on the session bus.""" + try: + out = subprocess.run( + ["gdbus", "call", "--session", "--dest", "org.a11y.Bus", + "--object-path", "/org/a11y/bus", "--method", + "org.a11y.Bus.GetAddress"], + env=dict(os.environ, DBUS_SESSION_BUS_ADDRESS=session_bus), + capture_output=True, text=True, timeout=10) + if out.returncode != 0: + return None + # ('unix:path=/run/user/1000/deskwright-headless/at-spi/bus',) + first = out.stdout.strip().strip("()").split(",")[0].strip().strip("'\"") + return first[len("unix:path="):] if first.startswith("unix:path=") else None + except (subprocess.TimeoutExpired, OSError): + return None + + +def registry_owned(a11y_bus: str) -> bool: + """Does org.a11y.atspi.Registry have an owner on that a11y bus?""" + try: + out = subprocess.run( + ["gdbus", "call", "--address", f"unix:path={a11y_bus}", + "--dest", "org.freedesktop.DBus", "--object-path", + "/org/freedesktop/DBus", "--method", + "org.freedesktop.DBus.GetNameOwner", "org.a11y.atspi.Registry"], + capture_output=True, timeout=10) + return out.returncode == 0 + except (subprocess.TimeoutExpired, OSError): + return False + + +def main() -> int: + keep = "--keep" in sys.argv + args = sys.argv[1:] + name = None + if "--name" in args: + name = args[args.index("--name") + 1] + + try: + report = headless.ensure(name=name) + except Exception as e: + print(f"could not start a headless session: {type(e).__name__}: {e}") + return 2 + if not report.get("running"): + print("headless session did not come up:") + print(json.dumps(report, indent=1)) + return 2 + + address = report["bus_address"] + print(f"headless session {report['name']!r} running, " + f"shell pid {report.get('shell_pid')}") + print(f"log: {report.get('log')}") + + # Pin THIS process to the session before touching AT-SPI. ensure() + # deliberately does not pin (the server's entry point does it at + # startup, via deskwright/session.py), and an unpinned process reads + # the USER's a11y bus -- where the registry runs and everything looks + # fine. Forgetting the pin is exactly how this bug hides from a casual + # check: the broken bus is only visible from inside the session. + headless.pin_env(report, os.environ) + + # 1. The a11y bus itself must exist: its launcher does not depend on + # systemd, so this passes even with the bug present. + socket_path = a11y_socket(address) + check("a11y bus socket exists", + socket_path is not None and Path(socket_path).exists(), + socket_path or "org.a11y.Bus.GetAddress failed") + + # 2. The registry name must be owned ON THE A11Y BUS. This is the + # check that fails on main: the socket answers, nobody owns the + # registry name, every ui_* tool fails downstream. + alive = registry_owned(socket_path) if socket_path else False + check("org.a11y.atspi.Registry is owned", alive, + "" if alive else "socket answers but the registry name has no owner") + + # 3. And the tools must see it: ui_apps through the real tool surface. + # A registry that answers but exposes no apps would be its own bug. + # Retried for up to 10s: the shell's atk-bridge finishes its + # handshake a moment after the registry owns the name (measured: + # 0 apps immediately after start, apps present seconds later). + apps: dict = {} + deadline = time.monotonic() + 10.0 + while True: + try: + from deskwright.atspi import tool_ui_apps + apps = tool_ui_apps({}) + if apps["count"] >= 1 or time.monotonic() >= deadline: + break + except Exception as e: + if time.monotonic() >= deadline: + apps = {"count": -1, "error": f"{type(e).__name__}: {e}"} + break + time.sleep(0.5) + check("ui_apps sees apps through it", apps.get("count", 0) >= 1, + f"{apps.get('count')} apps: " + f"{', '.join(a['name'] for a in apps.get('apps', [])[:4])}") + + if not keep: + stopped = headless.stop(name=report["name"]) + print(f"cleanup: {'ended' if stopped.get('stopped') else 'nothing to end'}") + + print(f"\n{passed}/{passed + failed} checks passed") + if failed: + print("\nThe headless session's AT-SPI registry is not running. This is " + "the bug: the private bus cannot activate registryd through " + "systemd, so ui_* tools have nothing to talk to.") + return 1 + return 0 + +if __name__ == "__main__": + sys.exit(main())