152 lines
6.3 KiB
Python
152 lines
6.3 KiB
Python
#!/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()
|