"""EXPERIMENTAL applied-state/input headless campaign. See README.""" import argparse import json import os from pathlib import Path import re import shutil import signal import subprocess import tempfile import time parser = argparse.ArgumentParser() parser.add_argument('lab', type=Path) parser.add_argument('mode', choices=('control', 'private_standard', 'private_mosaic')) parser.add_argument('kind', choices=('cycles', 'buffer_cycles', 'identity', 'outside', 'hidden_click', 'toggle')) parser.add_argument('--cycles', type=int, default=3) parser.add_argument('--asi-mode', type=int, choices=(0, 1, 2), default=0) parser.add_argument('--test-hold', action='store_true') parser.add_argument('--test-timeout', action='store_true') parser.add_argument('--moves', type=int, default=18) parser.add_argument('--max-seconds', type=int, default=900) parser.add_argument('--max-captures', type=int, default=400) parser.add_argument('--max-bytes', type=int, default=250_000_000) args = parser.parse_args() if not 1 <= args.cycles <= 30 or not 1 <= args.moves <= 40: parser.error('cycle/move bounds exceeded') if args.kind in ('identity', 'buffer_cycles', 'hidden_click', 'toggle') and args.mode != 'private_mosaic': parser.error('camera/toggle requires private_mosaic') lab = args.lab.resolve() if not (lab / 'nested-client').is_file(): parser.error('prepared LAB required') run = lab / 'runs' / f'{args.mode}-{args.kind}-asi{args.asi_mode}-{time.time_ns()}' run.mkdir(parents=True, mode=0o700) runtime = Path(tempfile.mkdtemp(prefix='mtd-', dir='/tmp')) if len(os.fsencode(str(runtime / 'sway-ipc.4294967295.2147483647.sock'))) >= 108: raise RuntimeError('IPC socket path exceeds sockaddr_un') (run / 'runtime-path').write_text(str(runtime) + '\n') (run / 'config').write_text( 'output HEADLESS-1 resolution 800x600 position -800 0\n' 'output HEADLESS-2 resolution 800x600 position 0 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') for key in ('SWAYSOCK', 'I3SOCK', 'WAYLAND_DISPLAY', 'LD_LIBRARY_PATH'): env.pop(key, None) compositor_env = env.copy() compositor_env['ASI_MODE'] = str(args.asi_mode) if args.test_hold or args.test_timeout: compositor_env['ASI_TEST_HOLD_AFTER_PRESENT'] = '1' readback_dir = None if args.asi_mode: readback_dir = lab / 'buffers' / run.name readback_dir.mkdir(parents=True, mode=0o700) compositor_env['BI_OUTPUT_DIR'] = str(readback_dir) (run / 'asi-mode.json').write_text(json.dumps({'mode': args.asi_mode, 'readback_dir': str(readback_dir) if readback_dir else None}) + '\n') binary = Path('/usr/bin/sway') if args.mode == 'control' else lab / 'mosaic-build/sway/swayfx-mosaic' if args.mode != 'control': compositor_env['LD_LIBRARY_PATH'] = str(lab / 'scenefx-build') files, children, events = [], [], [] started = time.monotonic() capture_count = 0 capture_bytes = 0 def record(kind, **fields): entry = {'monotonic_ns': time.monotonic_ns(), 'kind': kind, **fields} events.append(entry) with (run / 'events.jsonl').open('a') as out: out.write(json.dumps(entry) + '\n') return entry def spawn(argv, log_name, *, stdin=False, debug=False): log = (run / log_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=subprocess.PIPE if stdin else None, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, text=stdin) children.append(proc) return proc def wait(predicate, seconds=12): deadline = time.monotonic() + seconds while time.monotonic() < deadline: if time.monotonic() - started > args.max_seconds: raise RuntimeError('run duration bound exceeded') result = predicate() if result: return result if compositor.poll() is not None: raise RuntimeError(f'compositor exited {compositor.returncode}') if 'pointer' in globals() and pointer.poll() is not None: raise RuntimeError(f'pointer exited {pointer.returncode}') if 'client' in globals() and client.poll() is not None: raise RuntimeError(f'client exited {client.returncode}') time.sleep(.04) raise RuntimeError('timeout waiting for event') def ipc(*argv): completed = subprocess.run(['swaymsg', '-s', str(sock), *argv], env=env, text=True, capture_output=True, check=True, timeout=8) result = json.loads(completed.stdout) record('ipc', argv=list(argv), result=result) return result def command(value): result = ipc(value) if not isinstance(result, list) or not result or not all(x.get('success') for x in result): raise RuntimeError(f'IPC command rejected: {value}: {result}') def find(node, predicate): if isinstance(node, dict): if predicate(node): return node for child in node.get('nodes', []) + node.get('floating_nodes', []): found = find(child, predicate) if found: return found return None def tree(): return ipc('-t', 'get_tree') def workspace(): 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 client_text(): return (run / 'client.log').read_text() def count(marker): return client_text().count(marker) def configured(name): matches = re.findall(rf'{name} ROLE_CONFIGURE x=(-?\d+) y=(-?\d+) w=(\d+) h=(\d+)', client_text()) if not matches: raise RuntimeError(f'no {name} role configure') return tuple(map(int, matches[-1])) def snapshot(tag): global capture_count, capture_bytes t = tree() outputs = ipc('-t', 'get_outputs') (run / f'tree-{tag}.json').write_text(json.dumps(t, indent=2) + '\n') (run / f'outputs-{tag}.json').write_text(json.dumps(outputs, indent=2) + '\n') expected = {'HEADLESS-1': (-800, 0, 800, 600), 'HEADLESS-2': (0, 0, 800, 600)} actual = {} for output in outputs: if output.get('name') in expected and output.get('active'): rect = output['rect'] if output.get('scale') != 1.0 or output.get('transform') != 'normal': raise RuntimeError('output scale/transform mismatch') actual[output['name']] = (rect['x'], rect['y'], rect['width'], rect['height']) if actual != expected: raise RuntimeError(f'output geometry mismatch: {actual}') for output, side in (('HEADLESS-1', 'left'), ('HEADLESS-2', 'right')): if capture_count >= args.max_captures: raise RuntimeError('capture count bound exceeded') path = run / f'{side}-{tag}.png' before = time.monotonic_ns() subprocess.run(['grim', '-o', output, str(path)], env=env, check=True, stdout=subprocess.DEVNULL, timeout=10) after = time.monotonic_ns() capture_count += 1 capture_bytes += path.stat().st_size if capture_bytes > args.max_bytes: raise RuntimeError('capture byte bound exceeded') record('capture', tag=tag, output=output, path=path.name, before_ns=before, after_ns=after, size=path.stat().st_size) def inject(action, x, y): if not (-800 <= x < 800 and 0 <= y < 600): raise ValueError('global pointer out of bounds') sent = x + 800 marker = f'INJECT_DONE {action} {sent},{y}' before = (run / 'pointer.log').read_text().count(marker) record('pointer_request', action=action, global_point=[x, y], sent=[sent, y], extent=[1600, 600], output='HEADLESS-1' if x < 0 else 'HEADLESS-2') pointer.stdin.write(f'{action} {sent} {y}\n') pointer.stdin.flush() wait(lambda: (run / 'pointer.log').read_text().count(marker) > before) def client_command(value): marker = f'COMMAND {value} DONE' before = count(marker) record('client_request', value=value) client.stdin.write(value + '\n') client.stdin.flush() wait(lambda: count(marker) > before) def scroll(target): current = workspace()['mosaic']['rows'][0]['scroll_x'] command(f'layout mosaic scroll {target-current:g}') wait(lambda: workspace()['mosaic']['rows'][0]['scroll_x'] == target) record('camera_observed', target=target, owner_id=owner()['id']) def open_chain(cycle): a_before, b_before = count('POPUP_A MAP'), count('POPUP_B MAP') o = owner() scroll_x = workspace()['mosaic']['rows'][0]['scroll_x'] if args.mode == 'private_mosaic' else 0 top_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + 100) inject('move', top_x, 150) inject('left', top_x, 150) wait(lambda: count('POPUP_A MAP') > a_before) snapshot(f'{cycle}-S1-A') a_cfg = configured('POPUP_A') a_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + 40) a_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + 35) inject('move', a_x, a_y) inject('left', a_x, a_y) wait(lambda: count('POPUP_B MAP') > b_before) if readback_dir: (readback_dir / 'enable').touch() snapshot(f'{cycle}-S2-B') b_cfg = configured('POPUP_B') b_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + b_cfg[0] + 35) b_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + b_cfg[1] + 30) inject('move', b_x, b_y) inject('right', b_x, b_y) time.sleep(.12) snapshot(f'{cycle}-S3-visible') record('chain', cycle=cycle, owner_id=o['id'], scroll_x=scroll_x, a_configure=a_cfg, b_configure=b_cfg, top_global=[top_x, 150], a_global=[a_x, a_y], b_global=[b_x, b_y]) def cycle_once(number): label = f'cycle-{number:03d}' record('cycle_start', cycle=number) snapshot(f'{label}-S0-reference') open_chain(label) if args.mode == 'private_mosaic': scroll(1280) snapshot(f'{label}-S4-hidden-immediate') time.sleep(.18) snapshot(f'{label}-S4-hidden-stable') inject('move', -700, 190) time.sleep(.1) snapshot(f'{label}-S4-hidden-hover') scroll(400) snapshot(f'{label}-S5-return-immediate') time.sleep(.18) snapshot(f'{label}-S5-return-stable') client_command('close_b') snapshot(f'{label}-S6-close-B-immediate') time.sleep(.18) snapshot(f'{label}-S6-close-B-stable') a_cfg = configured('POPUP_A') o = owner() scroll_x = workspace()['mosaic']['rows'][0]['scroll_x'] if args.mode == 'private_mosaic' else 0 a_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + 20) a_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + 20) before_a = len(re.findall(r'POINTER_BUTTON target=POPUP_A[^\n]*button=273 state=1', client_text())) inject('move', a_x, a_y) inject('right', a_x, a_y) wait(lambda: len(re.findall(r'POINTER_BUTTON target=POPUP_A[^\n]*button=273 state=1', client_text())) > before_a) snapshot(f'{label}-S6-A-interaction') client_command('close_a') snapshot(f'{label}-S7-close-A-immediate') time.sleep(.18) snapshot(f'{label}-S7-close-A-stable') record('cycle_end', cycle=number, owner_id=owner()['id'], client_alive=client.poll() is None) compositor_log = (run / 'compositor.log').open('w') files.append(compositor_log) compositor = None failure = 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) snapshot('startup') pointer = spawn([str(lab / 'pointer-daemon'), '1600', '600'], 'pointer.log', stdin=True) wait(lambda: 'POINTER_READY' in (run / 'pointer.log').read_text()) command('workspace 1') command('layout mosaic' if args.mode == 'private_mosaic' else 'layout splith') spawn(['kitty', '--class', 'A', '-o', 'cursor_blink_interval=0', '-o', 'background=#aa2222', '/usr/bin/sleep', '900'], 'A.log') wait(lambda: len(workspace()['nodes']) >= 1) client = spawn([str(lab / 'nested-client'), 'b_cross'], 'client.log', stdin=True, debug=True) wait(lambda: owner() and 'TOPLEVEL MAP' in client_text()) for name in ('C', 'D'): spawn(['kitty', '--class', name, '-o', 'cursor_blink_interval=0', '/usr/bin/sleep', '900'], f'{name}.log') time.sleep(.25) command('workspace 2') spawn(['stdbuf', '-oL', 'wev'], 'wev.log') wait(lambda: find(tree(), lambda n: n.get('app_id') == 'wev')) command('workspace 1') command('[app_id="org.mosaic.NestedGrabProbe"] focus') if args.mode == 'private_mosaic': scroll(400) time.sleep(.25) if args.kind == 'cycles': for i in range(1, args.cycles + 1): cycle_once(i) elif args.kind == 'buffer_cycles': if args.cycles > 3: parser.error('buffer probe permits at most three pairs') open_chain('buffer') snapshot('buffer-visible-start') if args.test_timeout: time.sleep(2.3) for i in range(args.cycles): record('buffer_pair_start', pair=i + 1, owner_id=owner()['id']) scroll(1280) snapshot(f'buffer-hidden-{i+1}-immediate') time.sleep(.18) snapshot(f'buffer-hidden-{i+1}-stable') scroll(400) snapshot(f'buffer-return-{i+1}-immediate') time.sleep(.18) snapshot(f'buffer-return-{i+1}-stable') record('buffer_pair_end', pair=i + 1, owner_id=owner()['id'], a_maps=count('POPUP_A MAP'), b_maps=count('POPUP_B MAP')) # Six accepted IPC requests are observations, not six promised frames. for i in range(6): target = 1280 if i % 2 == 0 else 400 record('asi_rapid_request', index=i, target=target) scroll(target) snapshot('asi-rapid-return') o = owner() a_cfg, b_cfg = configured('POPUP_A'), configured('POPUP_B') b_x = int(o['rect']['x'] - workspace()['mosaic']['rows'][0]['scroll_x'] + o['window_rect']['x'] + a_cfg[0] + b_cfg[0] + 35) b_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + b_cfg[1] + 30) before_b = len(re.findall(r'POINTER_BUTTON target=POPUP_B[^\n]*button=273 state=1', client_text())) inject('move', b_x, b_y) inject('right', b_x, b_y) wait(lambda: len(re.findall(r'POINTER_BUTTON target=POPUP_B[^\n]*button=273 state=1', client_text())) > before_b) record('asi_B_delivered', global_point=[b_x, b_y]) client_command('close_b') client_command('close_a') snapshot('buffer-closed') elif args.kind == 'identity': # CONTRACT: ten settled changes are five complete 400/1280 pairs. # Twelve following requests omit intervening captures; acceptance is # recorded, but coalesced intermediate presentations remain possible. snapshot('identity-S0-reference') open_chain('identity') for i in range(10): target = 1280 if i % 2 == 0 else 400 record('transition_start', series='settled', index=i, target=target) scroll(target) snapshot(f'identity-settled-{i:02d}-immediate') time.sleep(.18) snapshot(f'identity-settled-{i:02d}-stable') for i in range(12): target = 1280 if i % 2 == 0 else 400 record('transition_start', series='rapid', index=i, target=target) scroll(target) if i % 4 == 3: snapshot(f'identity-rapid-{i:02d}') snapshot('identity-rapid-final') # CONTRACT: this input check follows camera return without closing or # recreating either XDG popup; a fresh button is delivered to B. o = owner() a_cfg, b_cfg = configured('POPUP_A'), configured('POPUP_B') current_scroll = workspace()['mosaic']['rows'][0]['scroll_x'] b_x = int(o['rect']['x'] - current_scroll + o['window_rect']['x'] + a_cfg[0] + b_cfg[0] + 35) b_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + b_cfg[1] + 30) before_b = len(re.findall(r'POINTER_BUTTON target=POPUP_B[^\n]*button=273 state=1', client_text())) inject('move', b_x, b_y) inject('right', b_x, b_y) wait(lambda: len(re.findall(r'POINTER_BUTTON target=POPUP_B[^\n]*button=273 state=1', client_text())) > before_b) record('post_return_B_button', global_point=[b_x, b_y], owner_id=o['id']) snapshot('identity-post-return-B-button') client_command('close_b') client_command('close_a') snapshot('identity-closed') elif args.kind == 'outside': snapshot('outside-S0-reference') open_chain('outside') inject('move', 100, 350) inject('left', 100, 350) time.sleep(.18) snapshot('outside-dismissed') elif args.kind == 'hidden_click': snapshot('hidden-click-S0-reference') open_chain('hidden-click') scroll(1280) snapshot('hidden-click-hidden') before_buttons = len(re.findall(r'POINTER_BUTTON target=POPUP_[AB][^\n]*state=1', client_text())) inject('move', -700, 190) inject('right', -700, 190) wait(lambda: 'POPUP_B POPUP_DONE' in client_text() and 'POPUP_A POPUP_DONE' in client_text()) after_buttons = len(re.findall(r'POINTER_BUTTON target=POPUP_[AB][^\n]*state=1', client_text())) record('hidden_click_target', popup_buttons_before=before_buttons, popup_buttons_after=after_buttons) snapshot('hidden-click-dismissed') scroll(400) snapshot('hidden-click-returned') else: snapshot('toggle-S0-reference') open_chain('toggle') command('layout splith') snapshot('toggle-standard-immediate') time.sleep(.4) snapshot('toggle-standard-stable') o = owner() a_cfg, b_cfg = configured('POPUP_A'), configured('POPUP_B') b_x = int(max(20, o['rect']['x'] + o['window_rect']['x'] + a_cfg[0] + b_cfg[0] + 20)) b_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + b_cfg[1] + 30) if b_x < 0: raise RuntimeError('standard B sample does not cross x=0') inject('move', b_x, b_y) inject('right', b_x, b_y) snapshot('toggle-standard-click') client_command('close_b') client_command('close_a') snapshot('toggle-closed') except Exception as exc: failure = repr(exc) record('failure', error=failure) finally: for proc in children + ([compositor] if compositor is not None else []): if proc.poll() is None: try: os.killpg(proc.pid, signal.SIGTERM) except ProcessLookupError: pass 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) (run / 'exit-status.json').write_text(json.dumps({ 'failure': failure, 'compositor': None if compositor is None else compositor.returncode, 'children': [{'argv': p.args, 'returncode': p.returncode} for p in children], 'captures': capture_count, 'capture_bytes': capture_bytes, 'duration_seconds': time.monotonic() - started}, indent=2) + '\n') for file in files: file.close() shutil.rmtree(runtime) record('cleanup', runtime_removed=not runtime.exists()) print(f'RUN={run} FAILURE={failure} CAPTURES={capture_count}') if failure: raise SystemExit(1)