#!/usr/bin/env python3 """Expériences TEST_ONLY ; jamais importé ni lancé par le code de production. CONTRAT : IPC exclusivement vers le socket du compositeur enfant headless. Les seuls processus arrêtés sont des enfants détenus par cette expérience. Les arbres conservés ne contiennent que des fenêtres synthétiques. """ import json import os from pathlib import Path import signal import subprocess import tempfile import time ROOT = Path(__file__).resolve().parent def run(argv, **kwargs): return subprocess.run(argv, text=True, capture_output=True, timeout=10, **kwargs) def walk(node): yield node for child in node.get("nodes", []) + node.get("floating_nodes", []): yield from walk(child) class Lab: def __init__(self): self.directory = Path(tempfile.mkdtemp(prefix="labfy-session-v2-TEST_ONLY-")) self.children = [] self.logs = [] self.socket = None # WHY : supprimer seulement les sockets laisse subsister un bus, # XAUTHORITY ou une configuration GTK/Mozilla du bureau. CONTRACT : # liste positive système ; aucun PATH/config/session utilisateur hérité. self.env = {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"} # Aucun enfant (GTK, Qt, CLI) ne consulte les configurations/états réels. for key, name in (("HOME", "home"), ("XDG_CONFIG_HOME", "config"), ("XDG_STATE_HOME", "state"), ("XDG_CACHE_HOME", "cache")): target = self.directory / name target.mkdir(mode=0o700) self.env[key] = str(target) # Outils TEST_ONLY sélectionnables sans remplacer le compositeur installé. # Le backend reste imposé à headless ; aucun socket hérité n'est réutilisé. self.env.update(XDG_RUNTIME_DIR=str(self.directory), TMPDIR=str(self.directory), WLR_BACKENDS="headless", WLR_HEADLESS_OUTPUTS="1", WLR_RENDERER=os.environ.get("LABFY_TEST_RENDERER", "gles2"), WLR_RENDERER_ALLOW_SOFTWARE="1", LIBGL_ALWAYS_SOFTWARE="true") def start(self, argv): log = (self.directory / f"child-{len(self.children)}.log").open("w") self.logs.append(log) proc = subprocess.Popen(argv, env=self.env, stdout=log, stderr=log, start_new_session=True) self.children.append(proc) return proc def __enter__(self): try: self.sway = self.start([os.environ.get("LABFY_TEST_SWAY", "sway"), "-c", str(ROOT / "headless.conf")]) end = time.monotonic() + 15 while time.monotonic() < end: sockets = list(self.directory.glob("sway-ipc.*.sock")) displays = [p for p in self.directory.glob("wayland-*") if p.is_socket()] if sockets and displays: self.socket = str(sockets[0]) self.env.update(SWAYSOCK=self.socket, WAYLAND_DISPLAY=displays[0].name) self.tree() return self if self.sway.poll() is not None: raise RuntimeError((self.directory / "child-0.log").read_text()[-6000:]) time.sleep(.1) raise RuntimeError("Délai de démarrage headless dépassé") except BaseException: self.__exit__(None, None, None) raise def ipc(self, command=None, kind=None): assert self.socket and self.socket.startswith(str(self.directory) + "/") args = ["swaymsg", "-s", self.socket, "-r"] args += ["-t", kind] if kind else [command] result = run(args, env=self.env) return json.loads(result.stdout) def command(self, text): reply = self.ipc(text) if not all(item.get("success") for item in reply): raise RuntimeError((text, reply)) return reply def tree(self): return self.ipc(kind="get_tree") def windows(self): return [n for n in walk(self.tree()) if n.get("app_id")] def kitty(self, identity): before = {n["id"] for n in self.windows()} self.start(["kitty", "--config", "NONE", "--class", identity, "--title", "TEST_ONLY", "-o", "linux_display_server=wayland", "-o", "confirm_os_window_close=0", "/usr/bin/sleep", "180"]) end = time.monotonic() + 10 while time.monotonic() < end: found = [n for n in self.windows() if n["id"] not in before and n["app_id"] == identity] if len(found) == 1: return found[0]["id"] time.sleep(.1) raise RuntimeError("Fenêtre Kitty TEST_ONLY absente") def empty(self, workspace): old = self.windows() for n in old: assert n["app_id"].startswith("TEST_ONLY") self.command(f'[con_id={n["id"]}] kill') self.wait_absent([n["id"] for n in old]) self.command(f'workspace "{workspace}"') time.sleep(.15) def wait_absent(self, ids): # Une réponse IPC à kill ne prouve pas encore le retrait du top-level. end = time.monotonic() + 5 while time.monotonic() < end: if not set(ids).intersection(n["id"] for n in self.windows()): return time.sleep(.05) raise RuntimeError("Fenêtre TEST_ONLY encore présente après fermeture") def shape(self, workspace): def node(n): if n.get("app_id"): return n["app_id"] children = [node(c) for c in n.get("nodes", [])] if len(children) == 1: return children[0] return {n.get("layout"): children} found = [n for n in walk(self.tree()) if n.get("type") == "workspace" and n.get("name") == workspace] return node(found[0]) if found else None def __exit__(self, *_): # INVARIANT : même après exception, aucun helper détenu ne reste stoppé. for proc in reversed(self.children): if proc.poll() is None: os.killpg(proc.pid, signal.SIGCONT) os.killpg(proc.pid, signal.SIGTERM) try: proc.wait(timeout=3) except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGKILL) proc.wait(timeout=3) for log in self.logs: log.close()