swayfx-mosaic/tooling/probes/step20c-saved-target-timeout-input/run.py

391 lines
16 KiB
Python

"""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)