"""One reserved headless run with a real held XDG configure per episode.""" import argparse import json import os import re from pathlib import Path import shutil import signal import subprocess import tempfile import time from PIL import Image, ImageChops from budget import reserve parser = argparse.ArgumentParser() parser.add_argument('lab', type=Path) args = parser.parse_args() args.mode = 'private_mosaic' lab = args.lab.resolve() if not (lab / 'saved-client').is_file() or not (lab / 'static-reference').is_file(): parser.error('prepared LAB required') state = reserve(lab, 'run') run = lab / 'runs' / f'timeout-mosaic-{time.time_ns()}' run.mkdir(parents=True, mode=0o700) (run / 'fixture-source.py').write_text(Path(__file__).read_text()) runtime = Path(tempfile.mkdtemp(prefix='mst-', dir='/tmp')) (run / 'runtime-path').write_text(str(runtime) + '\n') (run / 'config').write_text( 'output HEADLESS-1 resolution 800x600 scale 1 transform normal position -800 0\n' 'output HEADLESS-2 resolution 800x600 scale 1 transform normal position 0 0\n' 'workspace 1 output HEADLESS-1\nworkspace 2 output HEADLESS-2\n' 'focus_follows_mouse no\n') env = os.environ.copy() for key in ('SWAYSOCK', 'I3SOCK', 'WAYLAND_DISPLAY', 'LD_LIBRARY_PATH'): env.pop(key, None) env.update(XDG_RUNTIME_DIR=str(runtime), WLR_BACKENDS='headless', WLR_HEADLESS_OUTPUTS='2', WLR_RENDERER='pixman', SAVED_TRACE='1', SAVED_TARGET_TRACE='1', SAVED_HIT_TRACE='1') binary = lab / 'mosaic-build/sway/swayfx-mosaic' env['LD_LIBRARY_PATH'] = str(lab / 'scenefx-build') started = time.monotonic() captures = 0 events = [] processes = [] logs = [] failure = None compositor = None def record(kind, **kw): event = {'ns': time.monotonic_ns(), 'kind': kind, **kw} events.append(event) with (run / 'events.jsonl').open('a') as file: file.write(json.dumps(event) + '\n') def spawn(argv, name, *, stdin=False, use_env=None): file = (run / name).open('w') logs.append(file) proc = subprocess.Popen(argv, env=use_env or env, stdout=file, stderr=subprocess.STDOUT, start_new_session=True, stdin=subprocess.PIPE if stdin else None, text=stdin) processes.append(proc) return proc def wait(predicate, seconds=8): limit = time.monotonic() + seconds while time.monotonic() < limit: if time.monotonic() - started > 150: raise RuntimeError('150 second run bound exceeded') value = predicate() if value: return value if compositor and compositor.poll() is not None: raise RuntimeError(f'compositor exit {compositor.returncode}') time.sleep(.025) raise RuntimeError('wait timed out') def ipc(*argv): result = subprocess.run(['swaymsg', '-s', str(sock), *argv], env=env, capture_output=True, text=True, timeout=5, check=True) value = json.loads(result.stdout) record('ipc', argv=list(argv), result=value) return value def command(value): result = ipc(value) if not result or not all(x.get('success') for x in result): raise RuntimeError(f'IPC 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', []): match = find(child, predicate) if match: return match return None def client_log(): return (run / 'client.log').read_text() def compositor_log(): return (run / 'compositor.log').read_text() def client_command(value): marker = f'COMMAND {value} DONE' before = client_log().count(marker) client.stdin.write(value + '\n') client.stdin.flush() wait(lambda: client_log().count(marker) > before) record('client_command', value=value) def inject(action, x, y): if not -800 <= x < 800 or not 0 <= y < 600: raise ValueError('pointer point outside applied outputs') marker = f'INJECT_DONE {action} {x + 800},{y}' before = (run / 'pointer.log').read_text().count(marker) pointer.stdin.write(f'{action} {x + 800} {y} 1600 600\n') pointer.stdin.flush() wait(lambda: (run / 'pointer.log').read_text().count(marker) > before) record('pointer', action=action, global_point=[x, y], client_tail=client_log().splitlines()[-5:]) def snapshot(tag): global captures outputs = ipc('-t', 'get_outputs') actual = {o['name']: (o['rect']['x'], o['rect']['y'], o['rect']['width'], o['rect']['height']) for o in outputs if o['active']} if actual != {'HEADLESS-1': (-800, 0, 800, 600), 'HEADLESS-2': (0, 0, 800, 600)}: raise RuntimeError(f'output geometry invalid: {actual}') for name, side in (('HEADLESS-1', 'left'), ('HEADLESS-2', 'right')): reserve(lab, 'capture') path = run / f'{side}-{tag}.png' before = time.monotonic_ns() subprocess.run(['grim', '-s', '1', '-o', name, str(path)], env=env, check=True, timeout=5, stdout=subprocess.DEVNULL) after = time.monotonic_ns() captures += 1 size = Image.open(path).size if size != (800, 600): raise RuntimeError(f'PNG dimensions invalid: {size}') record('capture', tag=tag, output=name, path=path.name, before_ns=before, after_ns=after, size=size) (run / f'tree-{tag}.json').write_text(json.dumps(ipc('-t', 'get_tree'), indent=2) + '\n') def source_measure(): tree = ipc('-t', 'get_tree') workspace = find(tree, lambda n: n.get('type') == 'workspace' and n.get('name') == '1') owner = find(tree, lambda n: n.get('app_id') == 'org.mosaic.SavedBufferProbe') if not owner or not workspace: raise RuntimeError('target view or workspace absent') lines = [line for line in compositor_log().splitlines() if 'TARGET_SOURCE ns=' in line] if not lines: raise RuntimeError('target source scene buffer unmeasured') line = lines[-1] def field(name): match = re.search(rf'{name}=([^ ]+)', line) if not match: raise RuntimeError(f'missing {name}: {line}') return match.group(1) xy = [int(x) for x in field('global').split(',')] size = [int(x) for x in field('source_size').split(',')] dest = [int(x) for x in field('dest').split(',')] width = dest[0] or size[0] result = {'view': field('view'), 'container': field('con'), 'source_node': field('node'), 'source_buffer': field('buffer'), 'global_valid': field('global_valid') == '1', 'left': xy[0], 'top': xy[1], 'width': width, 'right': xy[0] + width, 'source_size': size, 'dest': dest, 'src_box': field('src_box'), 'transform': field('transform'), 'enabled': field('enabled'), 'owner': owner, 'workspace': workspace, 'raw': line} record('source_measure', measurement=result) return result def preflight_target(): global target_source before = source_measure() target_source = before if not before['global_valid'] or before['width'] <= 0 or before['enabled'] != '1': raise RuntimeError('source buffer geometry invalid') row = before['workspace']['mosaic']['rows'][0] current = row['scroll_x'] desired_left = -max(64, min(before['width'] // 2, before['width'] - 64)) delta = int(round(before['left'] - desired_left)) desired = current + delta if before['width'] < 128 or desired < 0: raise RuntimeError(f'crossing unreachable from measured source: current={current} delta={delta}') record('camera_calculation', current_scroll=current, desired_scroll=desired, delta=delta, desired_left=desired_left, source=before) if delta: reserve(lab, 'camera') command(f'layout mosaic scroll {delta}') time.sleep(.2) after = source_measure() target_source = after applied = after['workspace']['mosaic']['rows'][0]['scroll_x'] record('camera_applied', scroll=applied, source=after) if applied != desired or after['left'] >= -64 or after['right'] <= 64: raise RuntimeError(f'target source failed boundary preflight: {after["left"]},{after["right"]}, scroll={applied}') if after['source_node'] != before['source_node'] or after['source_buffer'] != before['source_buffer']: raise RuntimeError('source identity changed during camera adjustment') (lab / 'results').mkdir(exist_ok=True) (lab / 'results' / 'target-preflight.json').write_text(json.dumps({ 'before': before, 'after': after, 'current_scroll': current, 'desired_scroll': desired, 'delta': delta}, indent=2) + '\n') def reference_stability(): snapshot('reference-a') time.sleep(.7) snapshot('reference-b') left = Image.open(run / 'right-reference-a.png').convert('RGB') right = Image.open(run / 'right-reference-b.png').convert('RGB') difference = ImageChops.difference(left, right) box = difference.getbbox() stable = box is None target_colors = {(35, 68, 204), (32, 208, 160)} target_pixels = sum(pixel in target_colors for pixel in left.get_flattened_data()) (lab / 'results').mkdir(exist_ok=True) (lab / 'results' / 'reference-stability.json').write_text(json.dumps({ 'stable': stable, 'diff_box': box, 'target_pixels': target_pixels, 'first_sha256': sha256(run / 'right-reference-a.png'), 'second_sha256': sha256(run / 'right-reference-b.png'), 'first_app_id': 'org.mosaic.StaticReference'}, indent=2) + '\n') record('reference_stability', stable=stable, box=box, target_pixels=target_pixels) if not stable or target_pixels: raise RuntimeError('static H2 reference gate failed before transaction') def sha256(path): import hashlib return hashlib.sha256(path.read_bytes()).hexdigest() def episode(): global target_source # WHY: the source pointer belongs to this one episode, not a past ACK. target_source = source_measure() if not target_source['left'] < 0 < target_source['right']: raise RuntimeError('current source does not cross x=0') reserve(lab, 'episode') record('episode_start', source=target_source) client_command('hold_next_configure') old_held = client_log().count('HELD ns=') old_timeout = compositor_log().count('timed out') before_log = len(compositor_log()) command('gaps inner current set 30') wait(lambda: client_log().count('HELD ns=') > old_held, 3) held_line = [x for x in client_log().splitlines() if x.startswith('HELD ns=')][-1] held_ns = int(re.search(r'ns=(\d+)', held_line).group(1)) view = target_source['view'] def target_trace(): text = compositor_log()[before_log:] trees = [x for x in text.splitlines() if 'SB_TREE_CREATE ' in x and f'view={view}' in x] saved = [x for x in text.splitlines() if 'TARGET_SAVED ' in x and f'view={view}' in x] return (trees[-1], saved[-1]) if trees and saved else None tree_line, saved_line = wait(target_trace, 2) def field(line, name): match = re.search(rf'{name}=([^ ]+)', line) if not match: raise RuntimeError(f'missing {name}: {line}') return match.group(1) saved_node = field(saved_line, 'node') saved_buffer = field(saved_line, 'buffer') saved_tree = field(tree_line, 'tree') x, y = map(int, field(saved_line, 'global').split(',')) sw, sh = map(int, field(saved_line, 'source_size').split(',')) dw, dh = map(int, field(saved_line, 'dest').split(',')) box = [x, y, x + (dw or sw), y + (dh or sh)] record('saved_target', view=view, tree=saved_tree, node=saved_node, buffer=saved_buffer, source=target_source, box=box, tree_trace=tree_line, saved_trace=saved_line) if (saved_buffer != target_source['source_buffer'] or field(saved_line, 'global_valid') != '1' or field(saved_line, 'enabled') != '1' or sw <= 0 or sh <= 0 or not box[0] < 0 < box[2]): raise RuntimeError('actual saved target identity/geometry gate failed') wait(lambda: any('SB_RENDER ' in line and f'node={saved_node}' in line for line in compositor_log()[before_log:].splitlines()), 1) if f'view={view}' not in tree_line: raise RuntimeError('saved target view mismatch') snapshot('held') if time.monotonic_ns() - held_ns < 1200000000: # The H2 move precedes the H1 move so final H2 can be compared whole. inject('move', 100, 100) inject('move', max(-700, min(-100, (box[0] + min(box[2], 0)) // 2)), max(80, min(500, (box[1] + box[3]) // 2))) record('input_sampled', reference_tail=(run / 'reference.log').read_text().splitlines()[-10:], target_tail=client_log().splitlines()[-10:]) else: record('input_not_tested', reason='insufficient natural timeout window') wait(lambda: compositor_log().count('timed out') > old_timeout, 4) if client.poll() is not None: raise RuntimeError('target client exited before natural timeout') held_tail = client_log().split(held_line, 1)[-1] if 'ACK ns=' in held_tail: raise RuntimeError('target client ACKed held configure') record('timeout_observed', held_serial=field(held_line, 'serial'), client_alive=True, no_ack=True) wait(lambda: any('SB_TREE_DESTROY ' in line and f'view={view}' in line and f'tree={saved_tree}' in line for line in compositor_log()[before_log:].splitlines()), 2) record('target_tree_destroyed', tree=saved_tree) time.sleep(.2) snapshot('timeout-applied') if client.poll() is not None: raise RuntimeError('target client exited after timeout') try: file = (run / 'compositor.log').open('w') logs.append(file) compositor = subprocess.Popen([str(binary), '-c', str(run / 'config'), '-d', '-Dtxn-timeout=2500'], env=env, stdout=file, 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'), '1600', '600'], 'pointer.log', stdin=True) wait(lambda: 'POINTER_READY' in (run / 'pointer.log').read_text()) command('workspace 1') command('layout mosaic') spawn(['kitty', '--class', 'A', '/usr/bin/sleep', '900'], 'A.log') wait(lambda: len(find(ipc('-t', 'get_tree'), lambda n: n.get('type') == 'workspace' and n.get('name') == '1')['nodes']) >= 1) client = spawn([str(lab / 'saved-client')], 'client.log', stdin=True) wait(lambda: 'COMMIT ns=' in client_log()) for name in ('C', 'D'): spawn(['kitty', '--class', name, '/usr/bin/sleep', '900'], f'{name}.log') time.sleep(.2) command('workspace 2') reference = spawn([str(lab / 'static-reference')], 'reference.log', stdin=True) wait(lambda: 'COMMIT ns=' in (run / 'reference.log').read_text()) tree = ipc('-t', 'get_tree') ref = find(tree, lambda n: n.get('app_id') == 'org.mosaic.StaticReference') if not ref or ref['rect']['x'] != 0: raise RuntimeError('static reference not mapped on H2') record('static_reference', node=ref) command('workspace 1') time.sleep(.2) reference_stability() preflight_target() episode() except Exception as exc: failure = repr(exc) record('failure', message=failure) finally: for proc in processes + ([compositor] if compositor else []): if proc.poll() is None: try: os.killpg(proc.pid, signal.SIGTERM) except ProcessLookupError: pass for proc in processes + ([compositor] if compositor else []): try: proc.wait(timeout=5) except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGKILL) proc.wait(timeout=5) for file in logs: file.close() (run / 'exit-status.json').write_text(json.dumps({ 'failure': failure, 'captures': captures, 'duration_seconds': time.monotonic() - started, 'compositor': compositor.returncode if compositor else None, 'children': [{'args': p.args, 'code': p.returncode} for p in processes]}, indent=2) + '\n') shutil.rmtree(runtime) record('cleanup', runtime_removed=not runtime.exists()) print(f'RUN={run} FAILURE={failure} CAPTURES={captures}') if failure: raise SystemExit(1)