"""Isolated session-bus tests for the ScreenSaver/QuickShell bridge. Run with ``python tests/test_idle_bridge.py``; it creates its own private bus. The fake QuickShell owns only a private Unix socket; no daily bus is touched. """ import json import os import socket import subprocess import sys import tempfile import threading import time import unittest from pathlib import Path import gi gi.require_version("Gio", "2.0") from gi.repository import Gio, GLib ROOT = Path(__file__).resolve().parents[1] BRIDGE = ROOT / "quickshell/.config/quickshell/labfy-sway/idlebridge/bridge.py" NAME = "org.freedesktop.ScreenSaver" PATH = "/org/freedesktop/ScreenSaver" class FakeShell: def __init__(self, path): self.path = str(path) self.listener = socket.socket(socket.AF_UNIX) self.listener.bind(self.path) self.listener.listen(2) self.listener.settimeout(0.25) self.lock = threading.Condition() self.counts = [] self.connection = None self.running = True self.ack = True self.pending = [] self.thread = threading.Thread(target=self.run, daemon=True) self.thread.start() def run(self): while self.running: try: conn, _ = self.listener.accept() except socket.timeout: continue except OSError: return conn.settimeout(0.25) with self.lock: self.connection = conn self.counts.append(0) # QuickShell drops state on connect. self.lock.notify_all() data = bytearray() while self.running: try: chunk = conn.recv(1024) if not chunk: break data.extend(chunk) while b"\n" in data: line, _, rest = data.partition(b"\n") data = bytearray(rest) frame = json.loads(line) with self.lock: self.counts.append(frame["count"]) answer = {"type": "ack", "generation": frame["generation"], "sequence": frame["sequence"], "applied": True} if self.ack: conn.sendall((json.dumps(answer) + "\n").encode()) else: self.pending.append(answer) self.lock.notify_all() except socket.timeout: continue except OSError: break with self.lock: if self.connection is conn: self.connection = None self.counts.append(0) # QuickShell drops state on disconnect. self.lock.notify_all() conn.close() def wait_count(self, count, after=0, timeout=5): end = time.monotonic() + timeout with self.lock: while True: if count in self.counts[after:]: return len(self.counts) remaining = end - time.monotonic() if remaining <= 0: raise AssertionError(f"count {count} absent after {after}: {self.counts}") self.lock.wait(remaining) def release_ack(self): with self.lock: while self.pending and self.connection: frame = self.pending.pop(0) self.connection.sendall((json.dumps(frame) + "\n").encode()) self.ack = True def disconnect(self): with self.lock: if self.connection: try: self.connection.shutdown(socket.SHUT_RDWR) except OSError: pass def close(self): self.running = False self.disconnect() self.listener.close() self.thread.join(timeout=2) def bus_connection(): return Gio.DBusConnection.new_for_address_sync( os.environ["DBUS_SESSION_BUS_ADDRESS"], Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None) def call(conn, method, signature, args, result): return conn.call_sync(NAME, PATH, NAME, method, GLib.Variant(signature, args), GLib.VariantType.new(result), Gio.DBusCallFlags.NONE, 3000, None).unpack() class BridgeTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory(prefix="idle-bridge-test-") self.path = Path(self.temp.name) / "shell.sock" self.shell = FakeShell(self.path) env = dict(os.environ, LABFY_IDLE_BRIDGE_SOCKET=str(self.path), XDG_RUNTIME_DIR=self.temp.name) self.proc = subprocess.Popen([sys.executable, str(BRIDGE)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) self.a = bus_connection() self.b = bus_connection() end = time.monotonic() + 5 while time.monotonic() < end: try: if bus_connection().call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", GLib.Variant("(s)", (NAME,)), GLib.VariantType.new("(b)"), Gio.DBusCallFlags.NONE, 500, None).unpack()[0]: break except GLib.Error: pass time.sleep(0.05) else: raise AssertionError(f"bridge did not acquire name: {self.proc.poll()}") self.shell.wait_count(0) # Wait for the initial snapshot acknowledgment to reach the helper. for _ in range(30): try: call(self.a, "Inhibit", "(ss)", ("test", "one"), "(u)") self.shell.wait_count(1) break except GLib.Error: time.sleep(0.05) else: raise AssertionError("bridge did not become ready") # The setup request belongs to client A and is removed before each test. first = max(self.shell.counts) self.assertEqual(first, 1) # The only active cookie is obtained from the last test call by using # a dedicated second fresh setup in each test would be wasteful. Close # A and reopen it, relying on NameOwnerChanged cleanup. self.a.close_sync(None) self.shell.wait_count(0, after=2) self.a = bus_connection() def tearDown(self): for conn in (self.a, self.b): try: conn.close_sync(None) except GLib.Error: pass self.proc.terminate() try: self.proc.communicate(timeout=3) except subprocess.TimeoutExpired: self.proc.kill() self.proc.communicate() self.shell.close() self.temp.cleanup() def test_independent_cookies_and_ownership(self): start = len(self.shell.counts) one = call(self.a, "Inhibit", "(ss)", ("", ""), "(u)")[0] self.shell.wait_count(1, start) two = call(self.a, "Inhibit", "(ss)", ("same", "same"), "(u)")[0] self.shell.wait_count(2, start) three = call(self.b, "Inhibit", "(ss)", ("same", "same"), "(u)")[0] self.shell.wait_count(3, start) self.assertEqual(len({one, two, three}), 3) self.assertTrue(all((one, two, three))) with self.assertRaises(GLib.Error): call(self.b, "UnInhibit", "(u)", (one,), "()") self.assertEqual(self.shell.counts[-1], 3) call(self.a, "UnInhibit", "(u)", (one,), "()") self.shell.wait_count(2, start) with self.assertRaises(GLib.Error): call(self.a, "UnInhibit", "(u)", (one,), "()") with self.assertRaises(GLib.Error): call(self.a, "UnInhibit", "(u)", (0,), "()") self.a.close_sync(None) self.shell.wait_count(1, start) call(self.b, "UnInhibit", "(u)", (three,), "()") self.shell.wait_count(0, start) self.a = bus_connection() def test_reconnect_and_conflict(self): cookie = call(self.a, "Inhibit", "(ss)", ("test", "hold"), "(u)")[0] self.shell.wait_count(1) rival = subprocess.run([sys.executable, str(BRIDGE)], env=dict(os.environ, LABFY_IDLE_BRIDGE_SOCKET=str(self.path), XDG_RUNTIME_DIR=self.temp.name), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=3) self.assertEqual(rival.returncode, 2) self.shell.disconnect() self.shell.wait_count(0, after=len(self.shell.counts) - 1) self.shell.wait_count(1, after=len(self.shell.counts) - 1, timeout=5) call(self.a, "UnInhibit", "(u)", (cookie,), "()") self.shell.wait_count(0) def test_cancel_before_ack(self): with self.shell.lock: self.shell.ack = False # Async D-Bus call lets the test close the caller while QuickShell has # seen the provisional count but has not acknowledged it. done = threading.Event() self.a.call(NAME, PATH, NAME, "Inhibit", GLib.Variant("(ss)", ("test", "hold")), GLib.VariantType.new("(u)"), Gio.DBusCallFlags.NONE, 3000, None, lambda *_args: done.set()) mark = len(self.shell.counts) self.shell.wait_count(1, mark) self.a.close_sync(None) self.a = bus_connection() self.shell.release_ack() self.shell.wait_count(0, mark) def test_unavailable_shell_fails_new_acquisition(self): self.shell.close() time.sleep(0.4) with self.assertRaises(GLib.Error): call(self.a, "Inhibit", "(ss)", ("test", "hold"), "(u)") def test_cookie_not_reused_after_bridge_restart(self): first = call(self.a, "Inhibit", "(ss)", ("test", "hold"), "(u)")[0] call(self.a, "UnInhibit", "(u)", (first,), "()") self.proc.terminate() self.proc.communicate(timeout=3) env = dict(os.environ, LABFY_IDLE_BRIDGE_SOCKET=str(self.path), XDG_RUNTIME_DIR=self.temp.name) self.proc = subprocess.Popen([sys.executable, str(BRIDGE)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) end = time.monotonic() + 5 while time.monotonic() < end: try: second = call(self.a, "Inhibit", "(ss)", ("test", "again"), "(u)")[0] break except GLib.Error: time.sleep(0.05) else: raise AssertionError("restarted bridge did not become ready") self.assertGreater(second, first) with self.assertRaises(GLib.Error): call(self.a, "UnInhibit", "(u)", (first,), "()") call(self.a, "UnInhibit", "(u)", (second,), "()") if __name__ == "__main__": if os.environ.get("LABFY_IDLE_BRIDGE_PRIVATE_BUS") != "1": result = subprocess.run(["dbus-run-session", "--", "env", "LABFY_IDLE_BRIDGE_PRIVATE_BUS=1", sys.executable, str(Path(__file__).resolve()), *sys.argv[1:]]) sys.exit(result.returncode) unittest.main()