diff --git a/docs/design.md b/docs/design.md index 404393a..c57533e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -133,7 +133,7 @@ A few overlays need special handling: Head follow is experimental and off by default. It works, but it's only lightly tested, and the feel is mostly a matter of its settings; polishing it is left open. With it on (`POINTER_FOLLOW=1`, or a mouse button mapped to Head follow on/off), the cursor rides on a reference direction, where you were facing when your head last settled, and keeps its offset from it. The mouse can put the cursor anywhere up to `POINTER_FOLLOW_REACH` (70 degrees) from the reference, a corner of your view included. While your head stays within `POINTER_LEASH_DEG` of the reference, nothing moves on its own. Once your head has been past the leash for `POINTER_LEASH_DELAY` (0.2 s, so a glance out and back doesn't count), the reference eases to where you're facing (time constant `POINTER_LEASH_RETURN`, 0.2 s), never falling further behind than the leash, and the cursor ends up back where it was in your view. Then it waits for the leash again. Two earlier versions didn't work out. Moving the reference only while your head pulled at the end of the leash left it up to the leash off after you turned back, and getting it centred again meant overshooting with your head. Easing it toward your facing all the time moved the cursor on every small head movement. A leash of 0 makes the reference your facing direction, so the cursor is locked to your view, and mouse movement shifts it within the view. Head roll is ignored, so tilting your head doesn't swing the cursor around. While the left button is held the cursor stays put in the room, so your head can't nudge a click or a drag. When you let go, it carries on from where it is instead of jumping. -Gaze mode is experimental and off by default (`POINTER_GAZE=1`, the Gaze page of Frametop Input Settings, `gaze/ft-gazectl on`, or a mouse button or key combination mapped to Gaze pointer on/off). It's MAGIC pointing (Zhai, Morimoto and Ihde, 1999): the pointer goes where you look, and the mouse does the last bit. The gaze service (`gaze/ft-gazed`) sends the helper the corrected gaze at 90 Hz (from one eye while the tracker has lost the other), and while the gaze has the pointer, the cursor ray is that gaze from the eye. The pointer is aimed at the gaze each frame, not steered toward it, so nothing can pile up. An earlier try in the gaze probe steered the pointer with relative moves, and lost it when the pointer went idle or a controller had the laser. By default (`POINTER_GAZE_MOUSE_MOVE=held`, the Gaze page's Mouse movement switch) moving the mouse does nothing while the gaze has the pointer: it moves the pointer only while a button is held, as a correction. A bumped or drifting mouse can't pull the pointer off what you're looking at, and every mouse move is a correction, so the lessons aren't polluted by mouse moves to somewhere else (they used to be kept out by an 8 degree limit, which also dropped real corrections when the tracker was further off). With the gaze stale for a second, in a game, or with the headset off, the mouse moves the pointer as usual; with `free`, moving the mouse takes the pointer from the gaze. A left press while the gaze has the pointer isn't sent at once: the pointer stops where the gaze put it, you drag it onto what you meant with the button still down (panels only see it hover), and the release clicks there. Clicking at once clicked wherever the gaze was, often the wrong thing, before you could correct it. The drag is the correction. Snapping the pointer onto buttons and links is deferred: it needs accessibility (AT-SPI) on in the Frametop session, where it's off (no registry runs), plus app restarts, and it makes Chromium and Electron apps use more CPU. A press held still for `POINTER_GAZE_HOLD` (0.5 s) becomes a real press, so drags still work: hold, then move. The right button works the same way, with the right click on the release, and pressing it while the left press is held back starts a drag where the pointer is, like Meta+J then Meta+K. That drag lasts while either button (or key) is held, so a second right press, or a second Meta+K, is free to pan and tilt the panel being dragged; with the keyboard, the head turns it. Outside games the pointer then stays: the mouse going idle doesn't release it. A moving controller still releases it, as without gaze. Gaze mode is a mouse and keyboard feature: Steam reads the Frame controllers itself, outside SteamVR's bindings, so controller clicks at the gaze kept knocking SteamVR out of laser mode (see `docs/gaze-controllers.md`). Keyboard clicks (Meta+J, Meta+K) hold the dot still in your view while the keys are down, so the head, not the mouse, does the last bit; a quick tap clicks where the dot was at the press, since the head moves as you hit the keys. The relay hides Meta from the desktop as soon as such a combination fires, because KWin takes Meta with a mouse button as a window move or resize, which swallowed the clicks. The dot shows all the time by default. With `POINTER_GAZE_DOT=moving` it shows only while the mouse moves it (`POINTER_GAZE_SHOW`), while a press is held, and as a pulse for each click; otherwise it's transparent, so the laser still lands on it. Looking more than `POINTER_GAZE_RETAKE` (5 degrees) away from it, with the mouse still, gives it back, so small eye movements around the pointer don't pull it off what you're doing. A mouse nudge before a click whose correction is within `POINTER_GAZE_NUDGE_MAX` (55 degrees, half of what the headset shows across) is sent to the gaze service as a lesson: you were looking at where you clicked when the mouse took over, so the nudge is the eye tracker's error there. Using it is what calibrates it. A one-dot check in a panel fixed to the headset tops that up when the headset goes on, when our tracker thinks it moved, and when a correction is past that limit (the tracker is far off, so a click there isn't trusted as a lesson), and the full calibration and the headset fit check run in the same panel, so everything a user does to calibrate happens in one place in the headset; the gaze probe, a fullscreen GTK app, is the development tool. The limit was 8 degrees, which dropped every correction while our tracker was 12 off. Its dots sit at known directions from the headset, so the panel needs no screen geometry. The quick check's dot takes the gaze once it has held still, so what the tracker says doesn't have to be close for the capture to work. The full calibration's and the five-dot check's dots wait for a click while you look at the dot (a left click or Meta+J), because a steady gaze isn't always on the dot, and take the gaze held still up to the click; a right click or Meta+K stops. See `gaze/README.md` for the service, the calibration, and what was measured. +Gaze mode is experimental and off by default (`POINTER_GAZE=1`, the Gaze page of Frametop Input Settings, `gaze/ft-gazectl on`, or a mouse button or key combination mapped to Gaze pointer on/off). It's MAGIC pointing (Zhai, Morimoto and Ihde, 1999): the pointer goes where you look, and the mouse does the last bit. The gaze service (`gaze/ft-gazed`) sends the helper the corrected gaze at 90 Hz (from one eye while the tracker has lost the other), and while the gaze has the pointer, the cursor ray is that gaze from the eye. The pointer is aimed at the gaze each frame, not steered toward it, so nothing can pile up. An earlier try in the gaze probe steered the pointer with relative moves, and lost it when the pointer went idle or a controller had the laser. By default (`POINTER_GAZE_MOUSE_MOVE=held`, the Gaze page's Mouse movement switch) moving the mouse does nothing while the gaze has the pointer: it moves the pointer only while a button is held, as a correction. A bumped or drifting mouse can't pull the pointer off what you're looking at, and every mouse move is a correction, so the lessons aren't polluted by mouse moves to somewhere else (they used to be kept out by an 8 degree limit, which also dropped real corrections when the tracker was further off). With the gaze stale for a second, in a game, or with the headset off, the mouse moves the pointer as usual; with `free`, moving the mouse takes the pointer from the gaze. A left press while the gaze has the pointer isn't sent at once: the pointer stops where the gaze put it, you drag it onto what you meant with the button still down (panels only see it hover), and the release clicks there. Clicking at once clicked wherever the gaze was, often the wrong thing, before you could correct it. The drag is the correction. Snapping the pointer onto buttons and links is deferred: the session now starts an AT-SPI registry, but apps still need to expose useful accessibility trees (and may need restarting), and it makes Chromium and Electron apps use more CPU. A press held still for `POINTER_GAZE_HOLD` (0.5 s) becomes a real press, so drags still work: hold, then move. The right button works the same way, with the right click on the release, and pressing it while the left press is held back starts a drag where the pointer is, like Meta+J then Meta+K. That drag lasts while either button (or key) is held, so a second right press, or a second Meta+K, is free to pan and tilt the panel being dragged; with the keyboard, the head turns it. Outside games the pointer then stays: the mouse going idle doesn't release it. A moving controller still releases it, as without gaze. Gaze mode is a mouse and keyboard feature: Steam reads the Frame controllers itself, outside SteamVR's bindings, so controller clicks at the gaze kept knocking SteamVR out of laser mode (see `docs/gaze-controllers.md`). Keyboard clicks (Meta+J, Meta+K) hold the dot still in your view while the keys are down, so the head, not the mouse, does the last bit; a quick tap clicks where the dot was at the press, since the head moves as you hit the keys. The relay hides Meta from the desktop as soon as such a combination fires, because KWin takes Meta with a mouse button as a window move or resize, which swallowed the clicks. The dot shows all the time by default. With `POINTER_GAZE_DOT=moving` it shows only while the mouse moves it (`POINTER_GAZE_SHOW`), while a press is held, and as a pulse for each click; otherwise it's transparent, so the laser still lands on it. Looking more than `POINTER_GAZE_RETAKE` (5 degrees) away from it, with the mouse still, gives it back, so small eye movements around the pointer don't pull it off what you're doing. A mouse nudge before a click whose correction is within `POINTER_GAZE_NUDGE_MAX` (55 degrees, half of what the headset shows across) is sent to the gaze service as a lesson: you were looking at where you clicked when the mouse took over, so the nudge is the eye tracker's error there. Using it is what calibrates it. A one-dot check in a panel fixed to the headset tops that up when the headset goes on, when our tracker thinks it moved, and when a correction is past that limit (the tracker is far off, so a click there isn't trusted as a lesson), and the full calibration and the headset fit check run in the same panel, so everything a user does to calibrate happens in one place in the headset; the gaze probe, a fullscreen GTK app, is the development tool. The limit was 8 degrees, which dropped every correction while our tracker was 12 off. Its dots sit at known directions from the headset, so the panel needs no screen geometry. The quick check's dot takes the gaze once it has held still, so what the tracker says doesn't have to be close for the capture to work. The full calibration's and the five-dot check's dots wait for a click while you look at the dot (a left click or Meta+J), because a steady gaze isn't always on the dot, and take the gaze held still up to the click; a right click or Meta+K stops. See `gaze/README.md` for the service, the calibration, and what was measured. Replacing a loaded driver's files, as re-running the installer used to do, leaves SteamVR honoring the virtual controller's hand role but not its laser claim: the dashboard pointer stays unassigned until SteamVR restarts. The driver installer now leaves an unchanged driver in place. @@ -173,6 +173,18 @@ The session is modeled on SteamOS's `steamos-nested-desktop` and runs beside it. The VR launcher starts the session from the Steam client, and the client's environment came along: `LD_LIBRARY_PATH` pointing at Steam's own runtime, whose `libavcodec` has no H.264 decoder, so VLC in the desktop couldn't play most videos, plus the client's overlay and launch settings. The session script drops the client's variables before it starts anything. SteamOS's global Mesa settings (`/usr/share/deckard/mesavars.sh`) stay, and the gamescope session's Vulkan layer (`ENABLE_GAMESCOPE_WSI`) is only kept for the gamescope backend. +### Nested accessibility + +The session drops an inherited `AT_SPI_BUS_ADDRESS`, so apps cannot accidentally use the host desktop's registry. It autostarts `session/ft-atspi` in Plasma phase 2, after KWin has set the nested display environment. The helper gets the live accessibility address from `org.a11y.Bus` on the private session bus, preserves any existing registry owner, updates the accessibility bus's activation environment, and tries `StartServiceByName` first. + +On SteamOS 0.3.0 with at-spi2-core 2.52.0, the native launcher can choose dbus-broker because its process belongs to a systemd user unit. Registry activation then fails: this desktop's private session bus does not have a systemd activation manager. In that case the helper starts only `at-spi2-registryd` on the already-existing accessibility bus. The registry refuses duplicate ownership. Unlike native activation's `--use-gnome-session`, the fallback does not try to register with GNOME's session manager; that flag did not explain the observed native activation failure. + +The fallback registry does not exit merely when its bus disconnects in the isolated SteamOS test. Its small watcher checks both private buses every 5 seconds, and terminates and reaps only the child it started when either bus disappears or the watcher is stopped. Each check runs `gdbus` twice; once a second, that cost about 1% of a core. `keep-apps.sh` keeps the watcher in the desktop unit when `desktops.sh start` runs the desktop as `frametop-desktop`. Started from the VR launcher, the desktop runs in steam.service, which doesn't stop with it, so there the watcher is the only thing that stops the registry. There is no second accessibility bus, global systemd environment update, process-name kill, or host registry replacement. Missing accessibility files or bus errors are nonfatal; the desktop still starts. Toolkit-specific accessibility opt-ins and pointer snapping are separate work. + +Run the isolated checks on the host with `/usr/bin/python3 session/test/test_accessibility.py`. They use private D-Bus buses, Xvfb and a GTK3 app, never the production display or input. Native activation uses a small `org.a11y.Bus` test provider pointing to a real private dbus-daemon with the installed registry service; the SteamOS fallback uses the installed bus launcher and broker. The tests check real app-tree discovery, existing owners, concurrent starts, session stop/restart, and teardown. They require test-only PyGObject (Gio and GTK3), Xvfb, and at-spi2-core; the runtime helper uses Python's standard library and the existing host `gdbus`. Actual Plasma autostart and VR desktop restart still require an approved hardware test. + +### Other session behavior + Steam, not systemd, suspends the Frame: after `system_idle_suspend_ac_sec` (an hour by default) without input on AC power, it logs `Switching to power state: k_ESystemPowerState_Sleep` and suspends, even while charging. It's a Steam setting (Settings → Power → When Plugged In and Idle → Sleep after), which the Stay awake while plugged in switch in Frametop Display Settings sets to Never. SteamVR's standby, which turns the displays off when the headset comes off, is separate; see below. Flatpak apps need `XDG_DATA_DIRS` to include Flatpak's exports, or Plasma opens Discover instead of launching them, so the session sources `/etc/profile.d/flatpak.sh`. diff --git a/docs/reference.md b/docs/reference.md index 9abb61c..e84b47c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -18,6 +18,8 @@ desktops.sh start | stop | restart | status | log [lines] When the VR launcher starts the desktop, it inherits the Steam client's environment. The session script drops the client's runtime from it (`LD_LIBRARY_PATH`, the `STEAM_*` settings, and the Steam overlay's Vulkan layer), so apps in the desktop use the system's libraries, including its video codecs, just as they would after a normal login. +The nested session also starts an AT-SPI accessibility registry through `session/ft-atspi` in Plasma's autostart. It discovers the bus from this session, ignores an inherited host accessibility address, and leaves an existing registry alone. Accessibility errors do not stop the desktop. This supplies the registry infrastructure for apps that expose AT-SPI trees; it does not enable gaze snapping or force Chromium/Electron accessibility. After an approved desktop restart, an AT-SPI-aware app should be visible on the nested bus. See [design.md](design.md#nested-accessibility) for native activation, fallback lifecycle, and the isolated test command. + KWin's blur and background contrast effects and its animations are off in this desktop, because KWin draws on the headset's GPU, which SteamVR needs. The session script turns them off once, the first time it starts (it leaves a setting you already have alone, and marks it done in `~/.config/frametop/frametoprc`), so turning them back on sticks. In the Frametop desktop, System Settings → Window Management → Desktop Effects has Blur and Background Contrast, and General Behavior has Animation speed. Or from a terminal, then restart the desktop: ``` diff --git a/scripts/update-check.py b/scripts/update-check.py index 7af6175..0b320b8 100755 --- a/scripts/update-check.py +++ b/scripts/update-check.py @@ -49,6 +49,8 @@ PACKAGES = { "after a desktop restart; floating a window; no blur behind the taskbar's menus", "plasma-workspace": "the taskbar and panels after a desktop restart; no DiscoverNotifier or " "ibus-daemon inside the desktop", + "at-spi2-core": "an AT-SPI-aware app appears on the nested desktop's accessibility bus; " + "the registry stops and comes back after a desktop restart", "gamescope": "the headset's volume buttons with nothing focused; typing goes where you last clicked", "bluez": "a Bluetooth mouse reconnecting after it sleeps", } @@ -162,6 +164,14 @@ def check_host(): ("/usr/bin/kwin_wayland_wrapper", "FAIL", "the desktop can't start KWin"), ("/usr/bin/startplasma-wayland", "FAIL", "the desktop can't start Plasma"), ("/usr/bin/dbus-run-session", "FAIL", "the desktop can't start its session bus"), + ("/usr/bin/python3", "warn", "nested accessibility can't run its startup helper"), + ("/usr/bin/gdbus", "warn", "nested accessibility can't discover or check its bus"), + ("/usr/lib/at-spi-bus-launcher", "warn", "nested accessibility can't start its bus"), + ("/usr/lib/at-spi2-registryd", "warn", "nested accessibility has no fallback registry"), + ("/usr/share/dbus-1/services/org.a11y.Bus.service", "warn", + "nested accessibility can't activate its bus"), + ("/usr/share/dbus-1/accessibility-services/org.a11y.atspi.Registry.service", "warn", + "nested accessibility can't activate its registry natively"), (f"{STEAMVR_BIN}/vrcmd", "FAIL", "the 3D mouse can't find panels"), (f"{STEAMVR_BIN}/vrpathreg", "warn", "the pointer driver can't be installed or removed"), ("/usr/share/deckard/mesavars.sh", "warn", "the desktop starts without SteamOS's Mesa settings"), diff --git a/session/frametop-session.sh b/session/frametop-session.sh index 404fa36..4935e77 100755 --- a/session/frametop-session.sh +++ b/session/frametop-session.sh @@ -29,7 +29,7 @@ for var in $(compgen -e); do case $var in LD_LIBRARY_PATH | LD_PRELOAD | STEAM_* | Steam* | SRT_* | PRESSURE_VESSEL_* | MANGOHUD_* | \ ENABLE_VK_LAYER_VALVE_steam_overlay_* | STEAMVIDEOTOKEN | QT_IM_MODULE | GTK_IM_MODULE | \ - XMODIFIERS) unset "$var" ;; + XMODIFIERS | AT_SPI_BUS_ADDRESS) unset "$var" ;; esac done @@ -190,6 +190,21 @@ if [ "$remote" = 1 ]; then "$here/remote-ctl.sh" start fi +# AT-SPI: start the registry inside Plasma's autostart, after KWin has published the +# nested displays. Starting it before startplasma could bind to the host's X display. +# This config belongs only to Frametop; the host desktop's autostart is unchanged. +autostart=$XDG_CONFIG_HOME/autostart/frametop-atspi.desktop +mkdir -p "$(dirname "$autostart")" +cat > "$autostart" </dev/null) # The desktop's own processes stay in the unit and stop with it: the session, KWin, # Plasma, and the session services it started (portals, input methods, kded, wallet...). -own='^(frametop-sessi|dbus-|startplasma|plasma|kwin|Xwayland|ksmserver|krdpserver|Xvnc|xfreerdp|ft-layout|' +own='^(frametop-sessi|dbus-|startplasma|plasma|kwin|Xwayland|ksmserver|krdpserver|Xvnc|xfreerdp|ft-layout|ft-atspi|' own+='kded|kactivitymanage|kaccess|kglobalaccel|kscreen|kwalletd|ksecretd|polkit-kde|org_kde_|baloo|' own+='xembedsniproxy|gmenudbusmenu|DiscoverNotifie|kimpanel|ibus|xdg-|at-spi|dconf-service|fusermount|agent)' keep=() diff --git a/session/test/test_accessibility.py b/session/test/test_accessibility.py new file mode 100644 index 0000000..3cc30e3 --- /dev/null +++ b/session/test/test_accessibility.py @@ -0,0 +1,1132 @@ +#!/usr/bin/env python3 +"""Isolated AT-SPI integration tests. Run with the host's /usr/bin/python3. + +Requires test-only PyGObject (Gio, GTK3), Xvfb, and installed at-spi2-core. +All displays, session buses, config, and applications are private. No input is sent. +""" +from builtins import BaseExceptionGroup, ExceptionGroup +import ctypes +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import threading +import time +import unittest + +import gi +from gi.repository import Gio, GLib + +ROOT = Path(__file__).resolve().parents[2] +HELPER = ROOT / "session/ft-atspi" +DBUS = "org.freedesktop.DBus" +DBUS_PATH = "/org/freedesktop/DBus" +REGISTRY = "org.a11y.atspi.Registry" +ACCESSIBLE = "org.a11y.atspi.Accessible" + + +def connect(address): + return Gio.DBusConnection.new_for_address_sync( + address, Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT + | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None) + + +def call(bus, dest, path, interface, method, signature=None, args=()): + return bus.call_sync(dest, path, interface, method, + GLib.Variant(signature, args) if signature else None, + None, Gio.DBusCallFlags.NONE, 3000, None).unpack() + + +def eventually(predicate, timeout=8): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = predicate() + if result: + return result + time.sleep(0.05) + raise AssertionError("timed out waiting for " + predicate.__name__) + + +def process_running(pid): + try: + return Path(f"/proc/{pid}/stat").read_text().split(") ")[1][0] != "Z" + except FileNotFoundError: + return False + + +class ChildSignalAction(ctypes.Structure): + # Linux/glibc sigaction ABI on the 64-bit hosts used by this test harness. + _fields_ = [("sa_handler", ctypes.c_void_p), ("sa_mask", ctypes.c_byte * 128), + ("sa_flags", ctypes.c_int), ("sa_restorer", ctypes.c_void_p)] + + +def verify_private_waiter(): + # The standalone runner's main thread is the ONLY waiter for fixture leaders. + # No other thread/handler may wait* for them or change SIGCHLD while owned. + # Gio's worker threads are not waiters. Never install/change a global handler. + if (threading.current_thread() is not threading.main_thread() + or threading.enumerate() != [threading.main_thread()]): + raise AssertionError("private process lifecycle requires the sole main-thread waiter") + if sys.platform != "linux" or ctypes.sizeof(ctypes.c_void_p) != 8: + raise AssertionError("private process reservation requires 64-bit Linux/glibc") + action = ChildSignalAction() + libc = ctypes.CDLL(None, use_errno=True) + libc.sigaction.argtypes = [ctypes.c_int, ctypes.POINTER(ChildSignalAction), + ctypes.POINTER(ChildSignalAction)] + libc.sigaction.restype = ctypes.c_int + if libc.sigaction(signal.SIGCHLD, None, ctypes.byref(action)) != 0: + raise OSError(ctypes.get_errno(), "cannot verify SIGCHLD reservation") + if signal.getsignal(signal.SIGCHLD) != signal.SIG_DFL or action.sa_handler or action.sa_flags & 2: + raise AssertionError("private process reservation requires default SIGCHLD without SA_NOCLDWAIT") + + +class OwnedProcess(subprocess.Popen): + """Report exits without reaping: reserve the PID/PGID until teardown signals finish. + + This fixture is the sole waiter. Popen's poll/wait (including its finalizer) + must not free the group leader while activated descendants may still exist. + """ + def __init__(self, *args, **kwargs): + verify_private_waiter() + if not kwargs.get("start_new_session"): + raise AssertionError("private process must lead a new session/group") + super().__init__(*args, **kwargs) + try: + self.pidfd = os.pidfd_open(self.pid) + except BaseException: + # Verified default/no-autowait SIGCHLD and the sole-waiter contract + # keep even an exited leader reserved before pidfd acquisition. + # Do not leave a started private group behind on fixture setup failure. + try: + os.killpg(self.pid, signal.SIGKILL) + except ProcessLookupError: + pass + finally: + subprocess.Popen._wait(self, timeout=5) + if self.stdout: + self.stdout.close() + raise + + def _internal_poll(self, _deadstate=None): + if threading.current_thread() is not threading.main_thread(): + raise AssertionError("private process has a foreign waiter") + if self.returncode is None: + status = os.waitid(os.P_PIDFD, self.pidfd, os.WEXITED | os.WNOHANG | os.WNOWAIT) + if status is not None: + self.returncode = (status.si_status if status.si_code == os.CLD_EXITED + else -status.si_status) + return self.returncode + + def send_signal(self, sig): + try: + signal.pidfd_send_signal(self.pidfd, sig) + except ProcessLookupError: + pass # Exited identity: never reacquire or signal the numeric PID. + + def _wait(self, timeout): + deadline = None if timeout is None else time.monotonic() + timeout + while self._internal_poll() is None: + if deadline is not None and time.monotonic() >= deadline: + raise subprocess.TimeoutExpired(self.args, timeout) + time.sleep(0.01) + return self.returncode + + +class PrivateDesktop: + """Own exact child process groups; never discover/kill production by name.""" + def __init__(self, test, native=False, available=True): + self.test = test + (ROOT / "build").mkdir(exist_ok=True) + self.tmp = tempfile.TemporaryDirectory(prefix="a-", dir=ROOT / "build") + self.path = Path(self.tmp.name) + self.runtime = self.path / "frametop" + self.runtime.mkdir(mode=0o700) + self.env = {"PATH": "/usr/bin:/bin", "HOME": str(self.path), "LANG": "C.UTF-8", + "XDG_RUNTIME_DIR": str(self.runtime), "XDG_CONFIG_HOME": str(self.path / "config"), + "GSETTINGS_BACKEND": "memory", "NO_AT_BRIDGE": "0", "GDK_BACKEND": "x11", + "GTK_THEME": "Adwaita", "XDG_CURRENT_DESKTOP": "KDE"} + self.processes = [] + self.child_pidfds = [] + self.connections = [] + self.log = (ROOT / "build" / (test.id().split(".")[-1] + ".log")).open("w+") + self.native = native + self.available = available + + def spawn(self, argv, env=None, stdout=None): + p = OwnedProcess(argv, env=env or self.env, stdin=subprocess.DEVNULL, + stdout=stdout or self.log, stderr=self.log, text=True, + start_new_session=True) + self.processes.append(p) + return p + + def retain_child(self, pid, owner): + # Reserve the fixture group before trusting any freshly reported PID. + # No later acquisition from a stored/historical descendant ID is allowed. + if owner not in self.processes: + raise AssertionError("child owner is not this fixture's process") + os.waitid(os.P_PIDFD, owner.pidfd, os.WEXITED | os.WNOHANG | os.WNOWAIT) + fd = None + try: + with Path(f"/proc/{pid}/stat").open() as stat: + group = int(stat.read().rsplit(") ", 1)[1].split()[2]) + if group != owner.pid: + raise AssertionError("child is outside the reserved private group") + fd = os.pidfd_open(pid) + # The open proc inode is pinned to the original task. If that + # task was reaped while pidfd_open ran, this re-read fails rather + # than following a recycled PID to another task's stat file. + stat.seek(0) + if int(stat.read().rsplit(") ", 1)[1].split()[2]) != group: + raise AssertionError("private child changed process group") + os.waitid(os.P_PIDFD, owner.pidfd, os.WEXITED | os.WNOHANG | os.WNOWAIT) + self.child_pidfds.append(fd) + result, fd = fd, None + return result + finally: + if fd is not None: + os.close(fd) + + def bus(self, name, services): + config = self.path / (name + ".conf") + config.write_text("sessionunix:tmpdir=" + + str(self.path) + "" + "".join( + "" + str(d) + "" for d in services) + + '' + '' + '') + if name == "session": + p = self.spawn(["/usr/bin/dbus-run-session", "--config-file=" + str(config), "--", + "/usr/bin/python3", str(Path(__file__).resolve()), "--hold-session"], + stdout=subprocess.PIPE) + address = p.stdout.readline().strip() + self.session_child_fd = self.retain_child(int(p.stdout.readline().strip()), p) + else: + p = self.spawn(["/usr/bin/dbus-daemon", "--nofork", "--config-file=" + str(config), + "--print-address"], stdout=subprocess.PIPE) + address = p.stdout.readline().strip() + self.test.assertTrue(address.startswith("unix:"), "private bus failed to start") + conn = connect(address) + self.connections.append(conn) + return p, address, conn + + def __enter__(self): + try: + xvfb = self.spawn(["/usr/bin/Xvfb", "-displayfd", "1", "-screen", "0", "640x480x24", + "-nolisten", "tcp", "-noreset"], stdout=subprocess.PIPE) + self.env["DISPLAY"] = ":" + xvfb.stdout.readline().strip() + self.test.assertRegex(self.env["DISPLAY"], r"^:\d+$") + self.session, self.address, self.connection = self.bus( + "session", [] if self.native or not self.available else ["/usr/share/dbus-1/services"]) + self.env["DBUS_SESSION_BUS_ADDRESS"] = self.address + if self.native: + self.a11y_process, self.a11y_address, self.a11y = self.bus( + "a11y", ["/usr/share/dbus-1/accessibility-services"]) + self.provider = self.spawn(["/usr/bin/python3", str(Path(__file__).resolve()), + "--provide", self.address, self.a11y_address]) + eventually(lambda: call(self.connection, DBUS, DBUS_PATH, DBUS, "NameHasOwner", + "(s)", ("org.a11y.Bus",))[0]) + elif self.available: + self.a11y_address = call(self.connection, "org.a11y.Bus", "/org/a11y/bus", + "org.a11y.Bus", "GetAddress")[0] + self.a11y = connect(self.a11y_address) + self.connections.append(self.a11y) + if self.native or self.available: + broker = call(self.a11y, DBUS, DBUS_PATH, DBUS, + "GetConnectionUnixProcessID", "(s)", (DBUS,))[0] + self.a11y_broker_fd = self.retain_child( + broker, self.a11y_process if self.native else self.session) + return self + except BaseException: + self.__exit__(*sys.exc_info()) + raise + + def helper(self, env=None): + return self.spawn([str(HELPER)], env=env) + + def owner(self): + if not call(self.a11y, DBUS, DBUS_PATH, DBUS, "NameHasOwner", "(s)", (REGISTRY,))[0]: + return None + return call(self.a11y, DBUS, DBUS_PATH, DBUS, "GetConnectionUnixProcessID", + "(s)", (REGISTRY,))[0] + + def wait_owner(self, helper): + def registry_started(): + pid = self.owner() + if pid: + return pid + if helper.poll() is not None: + self.log.flush() + raise AssertionError("session startup did not start a registry: " + + (ROOT / "build" / (self.test.id().split(".")[-1] + ".log")).read_text()) + return None + return eventually(registry_started) + + def registry_env(self, pid): + return dict(s.split("=", 1) for s in Path(f"/proc/{pid}/environ").read_bytes().decode().split("\0") if "=" in s) + + def app_tree(self): + app = self.spawn(["/usr/bin/python3", str(Path(__file__).resolve()), "--gtk"]) + def names(): + found = [] + def visit(dest, path, depth=0): + if depth > 5: + return + name = call(self.a11y, dest, path, "org.freedesktop.DBus.Properties", + "Get", "(ss)", (ACCESSIBLE, "Name"))[0] + found.append(name) + for child_dest, child_path in call(self.a11y, dest, path, ACCESSIBLE, "GetChildren")[0]: + visit(child_dest, child_path, depth + 1) + visit(REGISTRY, "/org/a11y/atspi/accessible/root") + return found if "Private AT-SPI button" in found else None + tree = eventually(names) + self.test.assertIsNone(app.poll()) + print(f"TREE {self.test.id()}: {tree}", flush=True) + return tree + + def stop_session(self): + # End exactly the command owned by dbus-run-session, as Plasma ending would. + try: + signal.pidfd_send_signal(self.session_child_fd, signal.SIGTERM) + except ProcessLookupError: + pass # The retained command identity has exited; never reacquire its PID. + self.session.wait(timeout=5) + + def __exit__(self, *exc): + errors = [] + owners = {} + for p in self.processes: + try: + # WNOWAIT plus our sole-waiter OwnedProcess contract reserves the + # actual group leader, not a historical PID or a /proc timestamp. + os.waitid(os.P_PIDFD, p.pidfd, os.WEXITED | os.WNOHANG | os.WNOWAIT) + owners[p.pid] = p + except Exception as error: + errors.append(RuntimeError(f"lost ownership of private group {p.pid}: {error}")) + for conn in self.connections: + try: + if not conn.is_closed(): + conn.close_sync(None) + except Exception as error: + errors.append(error) + # All group signalling finishes BEFORE any owner is reaped. Even an + # exited leader reserves its PGID, so no recycled group can be targeted. + for sig in (signal.SIGTERM, signal.SIGKILL): + for pgid in reversed(owners): + try: + os.killpg(pgid, sig) + except ProcessLookupError: + pass + except Exception as error: + errors.append(error) + if sig == signal.SIGTERM: + deadline = time.monotonic() + 3 + for p in owners.values(): + try: + p.wait(timeout=max(0, deadline - time.monotonic())) + except Exception as error: + errors.append(error) + deadline = time.monotonic() + 5 + # A broken Popen.wait must not skip OS-level exit observation or reaping. + for pid in owners: + try: + while os.waitid(os.P_PIDFD, owners[pid].pidfd, os.WEXITED | os.WNOHANG | os.WNOWAIT) is None: + if time.monotonic() >= deadline: + raise AssertionError(f"private group owner {pid} survived SIGKILL") + time.sleep(0.01) + except Exception as error: + errors.append(error) + children = {} + try: + # Native D-Bus activation inherits these reserved groups, including + # grandchildren adopted by this test runner's subreaper. No ancestry + # expansion from transient/historical descendant identifiers. + try: + paths = list(Path("/proc").iterdir()) + except Exception as error: + errors.append(error) + paths = [] + for path in paths: + if not path.name.isdigit() or int(path.name) in owners: + continue + fd = None + try: + with (path / "stat").open() as stat: + group = int(stat.read().rsplit(") ", 1)[1].split()[2]) + if group not in owners: + continue + fd = os.pidfd_open(int(path.name)) + # This open proc file refers to the original task. Re-read + # AFTER acquiring the pidfd: recycling makes this read fail, + # rather than follow a newly allocated numeric PID. + stat.seek(0) + if int(stat.read().rsplit(") ", 1)[1].split()[2]) != group: + raise AssertionError("private child changed process group") + children[int(path.name)] = fd + fd = None + except (FileNotFoundError, ProcessLookupError): + pass + except Exception as error: + errors.append(error) + finally: + if fd is not None: + os.close(fd) + for pid, fd in children.items(): + try: + while True: + try: + status = os.waitid(os.P_PIDFD, fd, os.WEXITED | os.WNOHANG) + except ChildProcessError: + # A grandchild may have been reaped by its real parent. + # fdinfo is tied to this identity, not a reusable PID. + if "Pid:\t-1\n" in Path(f"/proc/self/fdinfo/{fd}").read_text(): + break + status = None + if status is not None: + break + if time.monotonic() >= deadline: + raise AssertionError(f"private child {pid} leaked during teardown") + time.sleep(0.01) + except Exception as error: + errors.append(error) + except Exception as error: + errors.append(error) + finally: + # Reap direct leaders through their ORIGINAL stable descriptors, + # independently of dup, proc discovery, descendant waits or cached + # Popen returncodes. Keep groups reserved through the entire scan. + for pid, p in owners.items(): + try: + while True: + status = os.waitid(os.P_PIDFD, p.pidfd, os.WEXITED | os.WNOHANG) + if status is not None: + p.returncode = (status.si_status if status.si_code == os.CLD_EXITED + else -status.si_status) + break + if time.monotonic() >= deadline: + raise AssertionError(f"private group owner {pid} was not reaped") + time.sleep(0.01) + except Exception as error: + errors.append(error) + for fd in self.child_pidfds: + try: + os.close(fd) + except Exception as error: + errors.append(error) + for fd in children.values(): + try: + os.close(fd) + except Exception as error: + errors.append(error) + for p in self.processes: + try: + os.close(p.pidfd) + except Exception as error: + errors.append(error) + try: + if p.stdout: + p.stdout.close() + except Exception as error: + errors.append(error) + try: + for error in errors: + print(f"TEARDOWN ERROR: {type(error).__name__}: {error}", file=self.log) + self.log.flush() + except Exception as error: + errors.append(error) + try: + self.log.close() + except Exception as error: + errors.append(error) + try: + self.tmp.cleanup() + self.test.assertFalse(self.path.exists()) + except Exception as error: + errors.append(error) + if errors: + if len(exc) > 1 and exc[1] is not None: + errors.insert(0, exc[1]) # Preserve the test/body failure as well. + raise BaseExceptionGroup("private desktop cleanup failed", errors) + print(f"TEARDOWN {self.test.id()}: private processes reaped; runtime removed", flush=True) + + +class CleanupTests(unittest.TestCase): + def test_pidfd_acquisition_failure_kills_reserved_group_then_consumes_wait(self): + import errno + from unittest.mock import Mock, patch + events = [] + stdout = Mock() + failure = OSError(errno.EMFILE, "pidfd acquisition exhausted") + verify = verify_private_waiter + + def checked_contract(): + verify() # Real, read-only SIGCHLD query; never change its disposition. + events.append("verified") + + def spawn(child, *args, **kwargs): + child.pid, child.returncode, child.stdout = 70001, None, stdout + child._child_created = False + events.append("spawn") + + def acquire(pid): + self.assertEqual(pid, 70001) + events.append("acquire") + raise failure + + def killpg(pid, sig): + self.assertEqual((pid, sig), (70001, signal.SIGKILL)) + events.append("last group signal") + + def consuming_wait(child, timeout): + self.assertEqual((child.pid, timeout), (70001, 5)) + events.append("consuming base wait") + child.returncode = -signal.SIGKILL + return child.returncode + + disposition = signal.getsignal(signal.SIGCHLD) + with patch.dict(globals(), verify_private_waiter=checked_contract), \ + patch.object(subprocess.Popen, "__init__", autospec=True, side_effect=spawn), \ + patch("os.pidfd_open", side_effect=acquire), patch("os.killpg", side_effect=killpg), \ + patch.object(subprocess.Popen, "_wait", autospec=True, side_effect=consuming_wait): + with self.assertRaises(OSError) as raised: + OwnedProcess(["/usr/bin/true"], start_new_session=True) + self.assertIs(raised.exception, failure) + self.assertEqual(events, ["verified", "spawn", "acquire", "last group signal", "consuming base wait"]) + stdout.close.assert_called_once_with() + self.assertEqual(signal.getsignal(signal.SIGCHLD), disposition) + + def test_emfile_still_consuming_reaps_original_owner_after_last_group_signal(self): + import errno + from unittest.mock import Mock, patch + desktop = PrivateDesktop(self, available=False) + child = Mock(pid=70001, pidfd=41, returncode=0, stdout=None) + desktop.processes = [child] # Already polled/waited: cached status is NOT a reap. + events = [] + + def waitid(idtype, fd, options): + self.assertEqual((idtype, fd), (os.P_PIDFD, 41)) + events.append(("observe" if options & os.WNOWAIT else "consume", fd)) + return Mock(si_status=0, si_code=os.CLD_EXITED) + + def close(fd): + events.append(("close", fd)) + + start = time.monotonic() + with patch("os.waitid", side_effect=waitid), \ + patch("os.killpg", side_effect=lambda pid, sig: events.append(("signal", sig))), \ + patch("os.dup", side_effect=OSError(errno.EMFILE, "dup exhausted")), \ + patch.object(Path, "iterdir", side_effect=OSError(errno.EMFILE, "scan exhausted")), \ + patch("os.close", side_effect=close): + with self.assertRaises(ExceptionGroup) as raised: + desktop.__exit__(None, None, None) + self.assertIn("scan exhausted", str(raised.exception.exceptions)) + self.assertEqual([event for event in events if event[0] == "consume"], [("consume", 41)], + "descriptor exhaustion skipped consuming waitid on the ORIGINAL pidfd") + self.assertLess(events.index(("signal", signal.SIGKILL)), events.index(("consume", 41))) + self.assertLess(events.index(("consume", 41)), events.index(("close", 41))) + self.assertTrue(desktop.log.closed) + self.assertFalse(desktop.path.exists()) + self.assertLess(time.monotonic() - start, 1) + + def test_owned_process_signalling_uses_original_pidfd(self): + import threading + from unittest.mock import patch + child = OwnedProcess.__new__(OwnedProcess) + child.pid, child.pidfd, child.returncode = 70002, 42, None + child._waitpid_lock = threading.Lock() + child._child_created = False + for method, sig in (("terminate", signal.SIGTERM), ("kill", signal.SIGKILL), + ("send_signal", signal.SIGINT)): + with self.subTest(method=method), patch.object(child, "_internal_poll", return_value=None), \ + patch("os.kill") as kill, patch("os.pidfd_open") as acquire, \ + patch("signal.pidfd_send_signal") as send: + getattr(child, method)(sig) if method == "send_signal" else getattr(child, method)() + kill.assert_not_called() + acquire.assert_not_called() + send.assert_called_once_with(42, sig) + + def test_owned_process_rejects_unsafe_waiter_contract_before_spawn(self): + import threading + from unittest.mock import Mock, patch + + def fake_spawn(child, *args, **kwargs): + child.pid, child.returncode, child.stdout = 70001, None, None + child._child_created = False + + def sigaction(sig, new, old): + self.assertEqual(sig, signal.SIGCHLD) + self.assertIsNone(new, "checking the waiter contract changed a global handler") + old._obj.sa_handler = None + old._obj.sa_flags = flags + return 0 + + libc = Mock(sigaction=Mock(side_effect=sigaction)) + for case, disposition, flags, current_thread, private in ( + ("ignored SIGCHLD", signal.SIG_IGN, 0, threading.main_thread(), True), + ("SA_NOCLDWAIT", signal.SIG_DFL, 2, threading.main_thread(), True), + ("foreign waiter thread", signal.SIG_DFL, 0, object(), True), + ("another Python waiter", signal.SIG_DFL, 0, threading.main_thread(), True), + ("unreserved group", signal.SIG_DFL, 0, threading.main_thread(), False)): + with self.subTest(case=case), patch("signal.getsignal", return_value=disposition), \ + patch("ctypes.CDLL", return_value=libc), \ + patch("threading.current_thread", return_value=current_thread), \ + patch("threading.enumerate", return_value=[threading.main_thread()] + + ([Mock()] if case == "another Python waiter" else [])), \ + patch.object(subprocess.Popen, "__init__", autospec=True, side_effect=fake_spawn) as spawn, \ + patch("os.pidfd_open", return_value=42), patch("os.killpg") as killpg: + with self.assertRaises(AssertionError): + OwnedProcess(["/usr/bin/true"], start_new_session=private) + spawn.assert_not_called() + killpg.assert_not_called() + + def test_broker_loss_signals_retained_identity_without_pid_lookup(self): + from unittest.mock import Mock, patch + desktop = Mock(a11y_broker_fd=43) + desktop.__enter__ = Mock(return_value=desktop) + desktop.__exit__ = Mock(return_value=False) + desktop.wait_owner.return_value = 70002 + desktop.helper.return_value.returncode = 0 + test = AccessibilityTests("test_accessibility_bus_loss_stops_only_owned_registry") + with patch.dict(globals(), PrivateDesktop=Mock(return_value=desktop), + call=Mock(return_value=(70002,)), process_running=Mock(return_value=False)), \ + patch("os.kill") as kill, patch("os.pidfd_open") as acquire, \ + patch("signal.pidfd_send_signal") as send: + test.test_accessibility_bus_loss_stops_only_owned_registry() + kill.assert_not_called() + acquire.assert_not_called() + send.assert_called_once_with(43, signal.SIGTERM) + + def test_desktop_setup_retains_broker_from_reserved_private_group(self): + from io import StringIO + from unittest.mock import Mock, patch + desktop = PrivateDesktop(self) + owner = Mock(pid=70001, pidfd=41) + desktop.processes = [owner] + xvfb = Mock(stdout=StringIO("42\n")) + stat = StringIO("70003 (broker) S 70001 70001 70001 0\n") + query = Mock(side_effect=[("unix:a11y",), (70003,)]) + try: + with patch.object(desktop, "spawn", return_value=xvfb), \ + patch.object(desktop, "bus", return_value=(owner, "unix:session", Mock())), \ + patch.dict(globals(), connect=Mock(), call=query), \ + patch.object(Path, "open", return_value=stat), \ + patch("os.waitid", return_value=None), patch("os.pidfd_open", return_value=44): + desktop.__enter__() + self.assertEqual(getattr(desktop, "a11y_broker_fd", None), 44, + "setup did not retain the private broker identity") + self.assertEqual(desktop.child_pidfds, [44]) + self.assertEqual(query.call_args.args[4], "GetConnectionUnixProcessID") + finally: + xvfb.stdout.close() + desktop.log.close() + desktop.tmp.cleanup() + + def test_session_command_acquisition_rejects_unproven_identity(self): + from io import StringIO + from unittest.mock import MagicMock, Mock, patch + desktop = PrivateDesktop(self, available=False) + owned = "70002 (command) S 70001 70001 70001 0\n" + foreign = "70002 (other) S 1 80001 80001 0\n" + try: + for case, first, second, wait_error in ( + ("foreign group", foreign, foreign, None), + ("recycled during acquisition", owned, ProcessLookupError(), None), + ("changed group", owned, foreign, None), + ("historical owner", owned, owned, ChildProcessError())): + with self.subTest(case=case): + owner = Mock(pid=70001, pidfd=41, stdout=StringIO("unix:private\n70002\n")) + desktop.processes = [owner] + stat = MagicMock() + stat.__enter__.return_value = stat + stat.read.side_effect = [first, second] + with patch.object(desktop, "spawn", return_value=owner), \ + patch.dict(globals(), connect=Mock()), patch.object(Path, "write_text"), \ + patch.object(Path, "open", return_value=stat), \ + patch("os.waitid", side_effect=wait_error, return_value=None), \ + patch("os.pidfd_open", return_value=42) as acquire, \ + patch("os.close") as close, patch("os.kill") as kill, \ + patch("signal.pidfd_send_signal") as send: + with self.assertRaises((AssertionError, ProcessLookupError, ChildProcessError)): + desktop.bus("session", []) + if case in ("foreign group", "historical owner"): + acquire.assert_not_called() + else: + close.assert_called_once_with(42) + kill.assert_not_called() + send.assert_not_called() + owner.stdout.close() + finally: + desktop.log.close() + desktop.tmp.cleanup() + + def test_session_command_identity_is_retained_during_bus_setup(self): + from io import StringIO + from unittest.mock import Mock, patch + desktop = PrivateDesktop(self, available=False) + owner = Mock(pid=70001, pidfd=41, stdout=StringIO("unix:private\n70002\n")) + desktop.processes = [owner] + desktop.child_pidfds = [] + stat = StringIO("70002 (command) S 70001 70001 70001 0\n") + try: + with patch.object(desktop, "spawn", return_value=owner), \ + patch.dict(globals(), connect=Mock()), patch.object(Path, "write_text"), \ + patch.object(Path, "open", return_value=stat), \ + patch("os.waitid", return_value=None) as reserve, \ + patch("os.pidfd_open", return_value=42) as acquire: + desktop.bus("session", []) + self.assertEqual(getattr(desktop, "session_child_fd", None), 42, + "bus setup did not retain the command's proven identity") + self.assertEqual(desktop.child_pidfds, [42]) + reserve.assert_called_with(os.P_PIDFD, 41, os.WEXITED | os.WNOHANG | os.WNOWAIT) + acquire.assert_called_once_with(70002) + finally: + owner.stdout.close() + desktop.log.close() + desktop.tmp.cleanup() + + def test_session_stop_uses_retained_identity_not_historical_pid(self): + from unittest.mock import Mock, patch + desktop = PrivateDesktop.__new__(PrivateDesktop) + desktop.session = Mock() + desktop.session_child = 70002 # Model an already-reaped wrapper command. + desktop.session_child_fd = 42 # Its original retained identity, never reused. + with patch("os.kill") as kill, patch("os.pidfd_open") as acquire, \ + patch("signal.pidfd_send_signal", side_effect=ProcessLookupError) as send: + desktop.stop_session() + kill.assert_not_called() + acquire.assert_not_called() + send.assert_called_once_with(42, signal.SIGTERM) + desktop.session.wait.assert_called_once_with(timeout=5) + + def test_native_adopted_children_cleanup_leaves_independent_fixture_alive(self): + # Both are private. No host display, host bus, PID recycling or broad kills. + from unittest.mock import patch + with PrivateDesktop(self) as sibling: + sibling_pid = sibling.wait_owner(sibling.helper()) + sibling_env = sibling.registry_env(sibling_pid) + native = PrivateDesktop(self, native=True) + real_killpg = os.killpg + + def owned_signal(pgid, sig): + self.assertIn(pgid, {p.pid for p in native.processes}) + # Audit every real, isolated signal while ownership is retained. + os.waitid(os.P_PID, pgid, os.WEXITED | os.WNOHANG | os.WNOWAIT) + real_killpg(pgid, sig) + + registry_fd = None + try: + with patch("os.killpg", side_effect=owned_signal), native: + helper = native.helper() + registry = native.wait_owner(helper) + helper.wait(timeout=8) + try: + status = os.waitid(os.P_PID, helper.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT) + except ChildProcessError: + self.fail("wait released the private group owner before cleanup") + self.assertIsNotNone(status) + self.assertEqual(os.getpgid(registry), native.a11y_process.pid) + self.assertEqual(int(Path(f"/proc/{registry}/stat").read_text() + .rsplit(") ", 1)[1].split()[1]), os.getpid(), + "native registry was not adopted by the test subreaper") + registry_fd = os.pidfd_open(registry) + native.app_tree() + native.stop_session() # Another waited leader stays reserved. + self.assertIn("Pid:\t-1\n", Path(f"/proc/self/fdinfo/{registry_fd}").read_text()) + self.assertFalse(native.path.exists()) + self.assertTrue(native.log.closed) + finally: + if registry_fd is not None: + os.close(registry_fd) + self.assertTrue(process_running(sibling_pid)) + self.assertEqual(sibling.owner(), sibling_pid) + self.assertEqual(sibling.registry_env(sibling_pid), sibling_env) + self.assertTrue(call(sibling.connection, DBUS, DBUS_PATH, DBUS, "GetId")[0]) + sibling.app_tree() + + def test_cleanup_aggregates_close_and_wait_failures_and_escalates(self): + from unittest.mock import Mock, patch + desktop = PrivateDesktop(self, available=False) + child = desktop.spawn(["/usr/bin/python3", "-c", + "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); " + "print('ready', flush=True); time.sleep(60)"], stdout=subprocess.PIPE) + self.assertEqual(child.stdout.readline().strip(), "ready") + pidfd = os.pidfd_open(child.pid) + bad = Mock() + bad.is_closed.return_value = False + bad.close_sync.side_effect = OSError("close failure") + good = Mock() + good.is_closed.return_value = False + desktop.connections.extend((bad, good)) + try: + start = time.monotonic() + with patch.object(child, "wait", side_effect=OSError("wait failure")), \ + patch("os.killpg", wraps=os.killpg) as killpg: + try: + desktop.__exit__(None, None, None) + except ExceptionGroup as errors: + self.assertIn("close failure", str(errors.exceptions)) + self.assertIn("wait failure", str(errors.exceptions)) + except OSError: + pass # RED must fail on incomplete cleanup, not the injected exception. + else: + self.fail("teardown silently discarded real failures") + self.assertFalse(desktop.path.exists(), "failure interrupted runtime cleanup") + self.assertTrue(desktop.log.closed) + good.close_sync.assert_called_once_with(None) + self.assertIn((child.pid, signal.SIGKILL), + [args for args, _ in killpg.call_args_list]) + self.assertLess(time.monotonic() - start, 8) + with self.assertRaises(ChildProcessError): + os.waitpid(child.pid, os.WNOHANG) + evidence = (ROOT / "build" / (self.id().split(".")[-1] + ".log")).read_text() + self.assertIn("close failure", evidence) + self.assertIn("wait failure", evidence) + finally: + # pidfd targets only the isolated child, even if RED aborted cleanup. + try: + signal.pidfd_send_signal(pidfd, signal.SIGKILL) + os.waitpid(child.pid, 0) + except (ProcessLookupError, ChildProcessError): + pass + os.close(pidfd) + if child.stdout: + child.stdout.close() + desktop.log.close() + desktop.tmp.cleanup() + + def test_proc_scan_failure_still_reaps_owned_children(self): + from unittest.mock import patch + desktop = PrivateDesktop(self, available=False) + child = desktop.spawn(["/usr/bin/true"]) + child.wait(timeout=5) + try: + with patch.object(Path, "iterdir", side_effect=OSError("scan failure")), \ + patch("os.killpg"): + with self.assertRaises(ExceptionGroup) as raised: + desktop.__exit__(None, None, None) + self.assertIn("scan failure", str(raised.exception.exceptions)) + self.assertFalse(desktop.path.exists()) + self.assertTrue(desktop.log.closed) + with self.assertRaises(ChildProcessError): + os.waitpid(child.pid, os.WNOHANG) + finally: + try: + os.waitpid(child.pid, os.WNOHANG) + except ChildProcessError: + pass + desktop.log.close() + desktop.tmp.cleanup() + + def test_recycled_numeric_child_identity_cannot_restore_group_ownership(self): + from unittest.mock import Mock, patch + desktop = PrivateDesktop(self, available=False) + child = desktop.spawn(["/usr/bin/true"]) + child.wait(timeout=5) + os.waitpid(child.pid, 0) + waitid = os.waitid + replacement = Mock(si_status=0, si_code=os.CLD_EXITED) + + def recycled_wait(idtype, identity, options): + # Model a different adopted child reusing the historical numeric PID. + # No real reuse, unrelated process or real signal is involved. + if idtype == os.P_PID and identity == child.pid: + return replacement + return waitid(idtype, identity, options) + + try: + with patch("os.waitid", side_effect=recycled_wait), patch("os.killpg") as killpg, \ + patch("os.kill") as kill, patch.object(Path, "iterdir", return_value=iter(())): + try: + desktop.__exit__(None, None, None) + except ExceptionGroup: + pass + killpg.assert_not_called() + kill.assert_not_called() + self.assertFalse(desktop.path.exists()) + self.assertTrue(desktop.log.closed) + finally: + desktop.log.close() + desktop.tmp.cleanup() + + def test_reaped_historical_group_is_not_signalled_or_discovered(self): + from unittest.mock import patch + desktop = PrivateDesktop(self, available=False) + child = desktop.spawn(["/usr/bin/true"]) + child.wait(timeout=5) + os.waitpid(child.pid, 0) # Deliberately revoke ownership; never force PID reuse. + historical = desktop.path / "70002" + historical.mkdir() + (historical / "stat").write_text(f"70002 (unrelated) S 1 {child.pid} 0\n") + try: + with patch("os.killpg") as killpg, patch("os.kill") as kill, \ + patch("os.pidfd_open") as pidfd_open, \ + patch.object(Path, "iterdir", return_value=iter((historical,))): + try: + desktop.__exit__(None, None, None) + except ExceptionGroup as errors: + self.assertIn("ownership", str(errors.exceptions)) + killpg.assert_not_called() + kill.assert_not_called() + pidfd_open.assert_not_called() + self.assertFalse(desktop.path.exists()) + self.assertTrue(desktop.log.closed) + finally: + desktop.log.close() + desktop.tmp.cleanup() + + def test_waited_and_polled_children_keep_group_ownership_until_last_signal(self): + from unittest.mock import patch + desktop = PrivateDesktop(self, available=False) + waited = desktop.spawn(["/usr/bin/true"]) + polled = desktop.spawn(["/usr/bin/true"]) + waited.wait(timeout=5) + eventually(lambda: polled.poll() is not None) + signals = [] + + def protected_group(pgid, sig): + # Mock every signal: never induce reuse or signal an unrelated group. + try: + status = os.waitid(os.P_PID, pgid, os.WEXITED | os.WNOHANG | os.WNOWAIT) + except ChildProcessError: + self.fail("cleanup signalled a reaped/historical process group") + self.assertIsNotNone(status, "the exited group owner must remain unreaped") + signals.append((pgid, sig)) + + try: + with patch("os.killpg", side_effect=protected_group): + desktop.__exit__(None, None, None) + self.assertEqual({pgid for pgid, _ in signals}, {waited.pid, polled.pid}) + for child in (waited, polled): + with self.assertRaises(ChildProcessError): + os.waitpid(child.pid, os.WNOHANG) + finally: + # Also keep RED safe if teardown aborts at the ownership assertion. + for child in (waited, polled): + try: + os.waitpid(child.pid, os.WNOHANG) + except ChildProcessError: + pass + desktop.log.close() + desktop.tmp.cleanup() + + +class AccessibilityTests(unittest.TestCase): + def test_accessibility_bus_loss_stops_only_owned_registry(self): + with PrivateDesktop(self) as desktop: + helper = desktop.helper() + pid = desktop.wait_owner(helper) + signal.pidfd_send_signal(desktop.a11y_broker_fd, signal.SIGTERM) + helper.wait(timeout=8) + self.assertEqual(helper.returncode, 0) + self.assertFalse(process_running(pid)) + self.assertTrue(call(desktop.connection, DBUS, DBUS_PATH, DBUS, "GetId")[0]) + + def test_watcher_termination_reaps_only_its_registry_child(self): + with PrivateDesktop(self) as desktop: + helper = desktop.helper() + pid = desktop.wait_owner(helper) + helper.terminate() + helper.wait(timeout=8) + self.assertEqual(helper.returncode, 0) + self.assertFalse(process_running(pid)) + self.assertTrue(call(desktop.connection, DBUS, DBUS_PATH, DBUS, "GetId")[0]) + self.assertTrue(call(desktop.a11y, DBUS, DBUS_PATH, DBUS, "GetId")[0]) + + def test_accessibility_watcher_stays_owned_by_desktop_unit(self): + source = (ROOT / "session/keep-apps.sh").read_text() + own = source[source.index("own='"):source.index("keep=()")] + result = subprocess.run(["bash", "-c", own + "\n[[ ft-atspi =~ $own ]]"], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, "desktop stop would preserve its accessibility watcher as an app") + + def test_update_check_reports_missing_accessibility_host_files(self): + import runpy + from unittest.mock import patch + module = runpy.run_path(str(ROOT / "scripts/update-check.py")) + check_host = module["check_host"] + missing = {"/usr/bin/python3", "/usr/bin/gdbus", "/usr/lib/at-spi-bus-launcher", "/usr/lib/at-spi2-registryd", + "/usr/share/dbus-1/services/org.a11y.Bus.service", + "/usr/share/dbus-1/accessibility-services/org.a11y.atspi.Registry.service"} + reports = [] + exists = os.path.exists + # Stub unrelated systemd/launcher checks; exercise the real host-file check. + with patch.dict(check_host.__globals__, + report=lambda *args: reports.append(args), + systemctl=lambda *args: "", launcher_session=lambda: None), \ + patch("os.path.exists", side_effect=lambda p: False if p in missing else exists(p)): + check_host() + reported = {args[1].removesuffix(" missing") for args in reports if args[0] == "warn"} + self.assertTrue(missing <= reported, "accessibility dependencies are not checked after SteamOS updates") + self.assertIn("at-spi2-core", module["PACKAGES"]) + + def test_session_stop_restart_uses_a_fresh_registry(self): + with PrivateDesktop(self) as desktop: + helper = desktop.helper() + pid = desktop.wait_owner(helper) + launcher = call(desktop.connection, DBUS, DBUS_PATH, DBUS, + "GetConnectionUnixProcessID", "(s)", ("org.a11y.Bus",))[0] + desktop.app_tree() + old_address = desktop.a11y_address + desktop.stop_session() + eventually(lambda: not process_running(pid)) + eventually(lambda: not process_running(launcher)) + print(f"STOP: registry {pid}, launcher {launcher} exited on private session-bus loss", flush=True) + with PrivateDesktop(self) as restarted: + helper = restarted.helper(dict(restarted.env, AT_SPI_BUS_ADDRESS=old_address)) + new_pid = restarted.wait_owner(helper) + self.assertNotEqual(new_pid, pid) + self.assertNotEqual(restarted.a11y_address, old_address) + self.assertEqual(restarted.registry_env(new_pid)["AT_SPI_BUS_ADDRESS"], restarted.a11y_address) + restarted.app_tree() + print(f"RESTART: fresh registry {new_pid} on {restarted.a11y_address}", flush=True) + + def test_existing_registry_is_not_replaced_or_reconfigured(self): + with PrivateDesktop(self) as desktop: + existing = desktop.spawn(["/usr/lib/at-spi2-registryd"], + env=dict(desktop.env, AT_SPI_BUS_ADDRESS=desktop.a11y_address)) + pid = desktop.wait_owner(existing) + before = desktop.registry_env(pid) + helper = desktop.helper() + helper.wait(timeout=8) + self.assertEqual(helper.returncode, 0) + self.assertEqual(desktop.owner(), pid) + self.assertEqual(desktop.registry_env(pid), before) + self.assertIsNone(existing.poll()) + + def test_concurrent_start_keeps_only_one_registry(self): + with PrivateDesktop(self) as desktop: + first, second = desktop.helper(), desktop.helper() + pid = desktop.wait_owner(first) + eventually(lambda: first.poll() is not None or second.poll() is not None) + self.assertEqual(desktop.owner(), pid) + self.assertEqual(sum(p.poll() is None for p in (first, second)), 1) + exited = first if first.poll() is not None else second + self.assertEqual(exited.returncode, 0) + + def test_inherited_host_registry_is_preserved(self): + # The "host" here is another PRIVATE Xvfb/bus, not the running desktop. + with PrivateDesktop(self) as host: + host_pid = host.wait_owner(host.helper()) + before = host.registry_env(host_pid) + with PrivateDesktop(self) as nested: + helper = nested.helper(dict(nested.env, AT_SPI_BUS_ADDRESS=host.a11y_address)) + nested_pid = nested.wait_owner(helper) + self.assertNotEqual(nested_pid, host_pid) + self.assertEqual(nested.registry_env(nested_pid)["AT_SPI_BUS_ADDRESS"], nested.a11y_address) + nested.app_tree() + self.assertEqual(host.owner(), host_pid) + self.assertEqual(host.registry_env(host_pid), before) + self.assertTrue(process_running(host_pid)) + + def test_unavailable_accessibility_does_not_fail_startup(self): + with PrivateDesktop(self, available=False) as desktop: + helper = desktop.helper() + helper.wait(timeout=8) + self.assertEqual(helper.returncode, 0) + self.assertFalse(call(desktop.connection, DBUS, DBUS_PATH, DBUS, + "NameHasOwner", "(s)", ("org.a11y.Bus",))[0]) + + def test_missing_gdbus_is_optional(self): + with PrivateDesktop(self) as desktop: + helper = desktop.spawn(["/usr/bin/python3", str(HELPER)], + env=dict(desktop.env, PATH="/unavailable")) + helper.wait(timeout=8) + self.assertEqual(helper.returncode, 0) + self.assertIsNone(desktop.owner()) + + def test_outside_nested_runtime_does_not_autolaunch_a_bus(self): + result = subprocess.run(["/usr/bin/python3", str(HELPER)], + env={"PATH": "/usr/bin:/bin", "XDG_RUNTIME_DIR": str(ROOT / "build"), + "DBUS_SESSION_BUS_ADDRESS": "unix:path=/not-a-session"}, + capture_output=True, text=True, timeout=8) + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stderr, "") + + def test_session_autostarts_registry_after_nested_display_is_ready(self): + source = (ROOT / "session/frametop-session.sh").read_text() + marker = "# AT-SPI:" + self.assertIn(marker, source, "the registry has no nested-session startup integration") + block = source[source.index(marker):source.index("# ft-floatd (floating windows)")] + with tempfile.TemporaryDirectory(prefix="autostart-", dir=ROOT / "build") as tmp: + result = subprocess.run(["bash", "-eu", "-c", block], + env={"PATH": "/usr/bin:/bin", "XDG_CONFIG_HOME": tmp, + "here": str(ROOT / "session")}, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + entry = Path(tmp, "autostart/frametop-atspi.desktop").read_text() + self.assertIn('Exec="' + str(HELPER) + '"', entry) + self.assertIn("X-KDE-autostart-phase=2", entry) + self.assertIn("OnlyShowIn=KDE;", entry) + self.assertTrue(os.access(HELPER, os.X_OK)) + + def test_session_drops_inherited_host_accessibility_address(self): + # Execute only the existing environment-sanitizing preamble, never the desktop. + preamble = (ROOT / "session/frametop-session.sh").read_text().split("conf=$HOME", 1)[0] + result = subprocess.run(["bash", "-c", preamble + "\n/usr/bin/printenv AT_SPI_BUS_ADDRESS"], + env={"PATH": "/usr/bin:/bin", "HOME": str(ROOT / "build"), + "AT_SPI_BUS_ADDRESS": "unix:path=/inherited-host-bus"}, + capture_output=True, text=True) + self.assertEqual(result.returncode, 1, "the nested session still inherits the host AT-SPI bus") + self.assertEqual(result.stdout, "") + + def test_steamos_broker_without_systemd_starts_one_registry(self): + with PrivateDesktop(self) as desktop: + helper = desktop.helper(dict(desktop.env, AT_SPI_BUS_ADDRESS="unix:path=/does-not-exist")) + pid = desktop.wait_owner(helper) + self.assertEqual(Path(f"/proc/{helper.pid}/comm").read_text().strip(), "ft-atspi") + self.assertEqual(desktop.registry_env(pid)["AT_SPI_BUS_ADDRESS"], desktop.a11y_address) + self.assertEqual(desktop.registry_env(pid)["DISPLAY"], desktop.env["DISPLAY"]) + second = desktop.helper() + second.wait(timeout=8) + self.assertEqual(second.returncode, 0) + self.assertEqual(desktop.owner(), pid) + desktop.app_tree() + + def test_native_activation_routes_registry_to_live_bus(self): + with PrivateDesktop(self, native=True) as desktop: + # A stale Steam/host address must never be used, including by activation. + env = dict(desktop.env, AT_SPI_BUS_ADDRESS="unix:path=/does-not-exist") + helper = desktop.helper(env) + pid = desktop.wait_owner(helper) + helper.wait(timeout=8) + self.assertEqual(helper.returncode, 0) + self.assertNotEqual(pid, helper.pid, "native activation should not manually spawn a registry") + self.assertEqual(desktop.registry_env(pid)["AT_SPI_BUS_ADDRESS"], desktop.a11y_address) + desktop.app_tree() + + +def provide(session, accessibility): + bus = connect(session) + xml = Gio.DBusNodeInfo.new_for_xml("" + "" + "") + def invoked(connection, sender, path, interface, method, parameters, invocation): + invocation.return_value(GLib.Variant("(s)", (accessibility,))) + bus.register_object("/org/a11y/bus", xml.interfaces[0], invoked, None, None) + call(bus, DBUS, DBUS_PATH, DBUS, "RequestName", "(su)", ("org.a11y.Bus", 4)) + GLib.MainLoop().run() + + +def gtk(): + gi.require_version("Gtk", "3.0") + from gi.repository import Gtk + window = Gtk.Window(title="Private Frametop AT-SPI fixture") + window.add(Gtk.Button(label="Private AT-SPI button")) + window.connect("destroy", Gtk.main_quit) + window.show_all() + Gtk.main() + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "--provide": + provide(*sys.argv[2:]) + elif len(sys.argv) > 1 and sys.argv[1] == "--hold-session": + print(os.environ["DBUS_SESSION_BUS_ADDRESS"], flush=True) + print(os.getpid(), flush=True) + GLib.MainLoop().run() + elif len(sys.argv) > 1 and sys.argv[1] == "--gtk": + gtk() + else: + # Make test teardown account for grandchildren, without touching unrelated processes. + ctypes.CDLL(None).prctl(36, 1, 0, 0, 0) # PR_SET_CHILD_SUBREAPER + unittest.main(verbosity=2)