"""EXPERIMENTAL isolated two-output nested XDG grab runner. Usage: python run.py LAB {control,private_standard,private_mosaic} SCENARIO PLACEMENT SCENARIO: open, visible_click, crossing, close_b, outside, hidden, hidden_click, owner, toggle PLACEMENT: visible, b_cross, both_cross """ import json import os import pathlib import shutil import signal import subprocess import sys import tempfile import time LAB = pathlib.Path(sys.argv[1]).resolve() case, scenario, placement = sys.argv[2:5] assert case in ('control', 'private_standard', 'private_mosaic') assert scenario in ('open', 'visible_click', 'crossing', 'close_b', 'outside', 'hidden', 'hidden_click', 'owner', 'toggle') assert placement in ('visible', 'b_cross', 'both_cross') assert LAB.is_dir() and (LAB / 'nested-client').is_file() run = LAB / f'run-{case}-{scenario}-{placement}' run.mkdir(mode=0o700, exist_ok=False) runtime = pathlib.Path(tempfile.mkdtemp(prefix='mng-', dir='/tmp')) # WHY: keep both Unix socket names below sockaddr_un.sun_path even if LAB is # long; the compositor derives its IPC path from XDG_RUNTIME_DIR. if len(os.fsencode(str(runtime / 'sway-ipc.4294967295.2147483647.sock'))) >= 108: raise RuntimeError('private IPC socket path exceeds Linux sockaddr_un.sun_path') (run / 'runtime-path').write_text(str(runtime) + '\n') (run / 'config').write_text( 'output HEADLESS-1 resolution 800x600 position 0 0\n' 'output HEADLESS-2 resolution 800x600 position 800 0\n' 'workspace 1 output HEADLESS-1\nworkspace 2 output HEADLESS-2\n' 'focus_follows_mouse no\n') env = os.environ.copy() env.update(XDG_RUNTIME_DIR=str(runtime), WLR_BACKENDS='headless', WLR_HEADLESS_OUTPUTS='2', WLR_RENDERER='pixman') env.pop('WAYLAND_DISPLAY', None) env.pop('SWAYSOCK', None) env.pop('I3SOCK', None) env.pop('LD_LIBRARY_PATH', None) compositor_env = env.copy() if case == 'control': binary = pathlib.Path('/usr/bin/sway') else: binary = LAB / 'mosaic-build/sway/swayfx-mosaic' compositor_env['LD_LIBRARY_PATH'] = str(LAB / 'scenefx-build') files = [] children = [] def spawn(argv, name, *, debug=False, stdin=None): log = (run / name).open('w') files.append(log) child_env = env.copy() if debug: child_env['WAYLAND_DEBUG'] = '1' proc = subprocess.Popen(argv, env=child_env, stdin=stdin, stdout=log, stderr=subprocess.STDOUT, text=True, start_new_session=True) children.append(proc) return proc def wait(fn, seconds=12): end = time.monotonic() + seconds while time.monotonic() < end: result = fn() if result: return result if compositor.poll() is not None: raise RuntimeError(f'compositor exited {compositor.returncode}') time.sleep(.05) raise RuntimeError('timeout waiting for probe event') def ipc(*args): return json.loads(subprocess.run(['swaymsg', '-s', str(sock), *args], env=env, text=True, capture_output=True, check=True, timeout=5).stdout) def find(node, predicate): if isinstance(node, dict): if predicate(node): return node for child in node.get('nodes', []) + node.get('floating_nodes', []): result = find(child, predicate) if result: return result return None def tree(): return ipc('-t', 'get_tree') def ws(): return find(tree(), lambda n: n.get('type') == 'workspace' and n.get('name') == '1') def owner(): return find(tree(), lambda n: n.get('app_id') == 'org.mosaic.NestedGrabProbe') def logged(s): path = run / 'client.log' return path.exists() and s in path.read_text() def capture(tag): (run / f'tree-{tag}.json').write_text(json.dumps(tree(), indent=2)) (run / f'outputs-{tag}.json').write_text(json.dumps(ipc('-t', 'get_outputs'), indent=2)) for output, label in (('HEADLESS-1', 'left'), ('HEADLESS-2', 'right')): subprocess.run(['grim', '-o', output, str(run / f'{label}-{tag}.png')], env=env, check=True, timeout=8, stdout=subprocess.DEVNULL) def inject(action, x, y): marker = f'INJECT_DONE {action} {x},{y}' pointer.stdin.write(f'{action} {x} {y}\n') pointer.stdin.flush() wait(lambda: marker in (run / 'pointer.log').read_text()) def command(value): client.stdin.write(value + '\n') client.stdin.flush() wait(lambda: f'COMMAND {value}' in (run / 'client.log').read_text()) compositor_log = (run / 'compositor.log').open('w') files.append(compositor_log) compositor = None try: compositor = subprocess.Popen([str(binary), '-c', str(run / 'config'), '-d'], env=compositor_env, stdout=compositor_log, stderr=subprocess.STDOUT, start_new_session=True) def sockets(): s = next(iter(runtime.glob('sway-ipc.*.sock')), None) w = next((p for p in runtime.glob('wayland-*') if p.suffix != '.lock'), None) return (s, w) if s and w else None sock, way = wait(sockets) env.update(SWAYSOCK=str(sock), WAYLAND_DISPLAY=way.name) pointer = spawn([str(LAB / 'pointer-daemon')], 'pointer.log', stdin=subprocess.PIPE) wait(lambda: 'POINTER_READY' in (run / 'pointer.log').read_text()) ipc('workspace 1') ipc('layout mosaic' if case == 'private_mosaic' else 'layout splith') spawn(['kitty', '--class', 'A', '-o', 'background=#aa2222', '/usr/bin/sleep', '90'], 'A.log') wait(lambda: len(ws()['nodes']) >= 1) client = spawn([str(LAB / 'nested-client'), placement], 'client.log', debug=True, stdin=subprocess.PIPE) wait(lambda: owner() and logged('TOPLEVEL MAP')) for label in ('C', 'D'): spawn(['kitty', '--class', label, '/usr/bin/sleep', '90'], label + '.log') time.sleep(.35) ipc('workspace 2') spawn(['stdbuf', '-oL', 'wev'], 'wev.log') time.sleep(.5) ipc('workspace 1') ipc('[app_id="org.mosaic.NestedGrabProbe"] focus') if case == 'private_mosaic': current = ws()['mosaic']['rows'][0]['scroll_x'] ipc(f'layout mosaic scroll {400-current:g}') time.sleep(.4) o = owner() scroll = ws()['mosaic']['rows'][0]['scroll_x'] if case == 'private_mosaic' else 0 rendered_x = o['rect']['x'] - scroll top_x = int(max(40, min(760, rendered_x + 100))) inject('left', top_x, 150) wait(lambda: logged('POPUP_A MAP')) anchor_a = {'visible': 50, 'b_cross': 200, 'both_cross': 350}[placement] a_x = int(max(40, min(760, rendered_x + anchor_a + 40))) inject('left', a_x, 150) wait(lambda: logged('POPUP_B MAP')) time.sleep(.3) capture('open') print('CHAIN_OPEN', case, scenario, placement, 'owner_id', o['id'], 'owner_rect', o['rect'], 'scroll', scroll, 'top_click', top_x, 'a_click', a_x, 'client_alive', client.poll() is None, flush=True) if scenario == 'visible_click': anchor_b = 20 b_x = int(max(40, min(760, rendered_x + anchor_a + anchor_b + 40))) inject('right', b_x, 190) time.sleep(.2) capture('child-click') print('B_CLICK_POINT', b_x, 190, flush=True) elif scenario == 'crossing': actions = [('move', 700, 190), ('right', 700, 190)] if placement == 'both_cross': actions += [('move', 780, 190), ('right', 780, 190)] actions += [('move', 900, 190), ('right', 900, 190)] for action, x, y in actions: inject(action, x, y) time.sleep(.2) capture(f'{action}-{x}') elif scenario == 'close_b': command('close_b') time.sleep(.3) capture('closed-b') inject('right', a_x, 150) time.sleep(.25) capture('parent-click') elif scenario == 'outside': inject('left', 900, 400) time.sleep(.4) capture('outside') elif scenario in ('hidden', 'hidden_click'): assert case == 'private_mosaic' ipc('layout mosaic scroll 880') time.sleep(.5) capture('hidden') print('HIDDEN_STATE', ws()['mosaic'], 'owner_id', owner()['id'], 'a_done', logged('POPUP_A POPUP_DONE'), 'b_done', logged('POPUP_B POPUP_DONE'), flush=True) inject('move', 100, 190) time.sleep(.2) capture('hidden-hover') if scenario == 'hidden_click': inject('right', 100, 190) time.sleep(.2) capture('hidden-click') ipc('layout mosaic scroll -880') time.sleep(.4) capture('returned') elif scenario == 'owner': ipc(f'[con_id={o["id"]}] kill') time.sleep(.4) capture('owner-destroyed') elif scenario == 'toggle': assert case == 'private_mosaic' ipc('layout splith') time.sleep(.4) capture('standard') inject('right', 900, 190) time.sleep(.2) capture('standard-click') print('FINAL', 'a_done', logged('POPUP_A POPUP_DONE'), 'b_done', logged('POPUP_B POPUP_DONE'), 'client_alive', client.poll() is None, flush=True) finally: for child in children: if child.poll() is None: os.killpg(child.pid, signal.SIGTERM) if compositor is not None and compositor.poll() is None: os.killpg(compositor.pid, signal.SIGTERM) # CONTRACT: only process groups started by this runner are terminated. # Reap them before removing their private socket directory. for proc in [*children, *([compositor] if compositor is not None else [])]: try: proc.wait(timeout=5) except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGKILL) proc.wait(timeout=5) for file in files: file.close() shutil.rmtree(runtime)