swayfx-mosaic/tooling/probes/step20c-saved-buffer-target-crossing/run.py

343 lines
14 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
from budget import reserve
parser = argparse.ArgumentParser()
parser.add_argument('lab', type=Path)
parser.add_argument('mode', choices=('control', 'private_standard', 'private_mosaic'))
args = parser.parse_args()
lab = args.lab.resolve()
if not (lab / 'saved-client').is_file():
parser.error('prepared LAB required')
state = reserve(lab, args.mode)
run = lab / 'runs' / f'saved-{args.mode}-{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')
binary = lab / ('standard-build/sway/sway' if args.mode == 'control'
else 'mosaic-build/sway/swayfx-mosaic')
if args.mode != 'control':
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, args.mode, capture=True)
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')
if args.mode != 'private_mosaic':
record('standard_preflight', source=before)
return
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, args.mode, camera=True)
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 episode(kind, gaps):
global target_source
# WHY: an ACK commits a new source buffer before the next episode.
# CONTRACT: remeasure it before reserving; never reuse the first pointer.
target_source = source_measure()
if args.mode == 'private_mosaic' and not (
target_source['left'] < 0 < target_source['right']):
raise RuntimeError('current target source no longer crosses x=0')
reserve(lab, args.mode, kind)
record('episode_start', episode=kind, gaps=gaps)
client_command('hold_next_configure')
old_held = client_log().count('HELD ns=')
old_tree = compositor_log().count('SB_TREE_CREATE')
command(f'gaps inner current set {gaps}')
wait(lambda: client_log().count('HELD ns=') > old_held, 3)
wait(lambda: compositor_log().count('SB_TREE_CREATE') > old_tree, 3)
record('held', client=client_log().splitlines()[-12:],
saved=compositor_log().splitlines()[-30:])
if args.mode == 'private_mosaic':
saved = [line for line in compositor_log().splitlines()
if 'TARGET_SAVED ns=' in line and f'view={target_source["view"]}' in line]
if not saved:
raise RuntimeError('target saved scene buffer absent')
line = saved[-1]
match = re.search(r'node=(0x[0-9a-f]+).*buffer=(0x[0-9a-f]+).*global_valid=(\d+) global=(-?\d+),(-?\d+).*source_size=(\d+),(\d+) dest=(\d+),(\d+).*enabled=(\d+)', line)
if not match:
raise RuntimeError(f'target saved trace incomplete: {line}')
node, buffer, valid, x, y, sw, sh, dw, dh, enabled = match.groups()
right = int(x) + (int(dw) or int(sw))
record('saved_target', node=node, buffer=buffer, source=target_source,
global_box=[int(x), int(y), right, int(y) + (int(dh) or int(sh))],
raw=line)
if buffer != target_source['source_buffer'] or valid != '1' or enabled != '1' or int(x) >= 0 or right <= 0:
raise RuntimeError('actual target saved buffer does not cross x=0')
snapshot(f'{kind}-held')
if args.mode == 'private_mosaic':
if not any(f'node={node}' in line for line in compositor_log().splitlines() if 'SB_RENDER ' in line):
raise RuntimeError('target saved node had no render entry')
if kind == 'ack':
old_destroy = compositor_log().count('SB_TREE_DESTROY')
client_command('release_configure')
wait(lambda: compositor_log().count('SB_TREE_DESTROY') > old_destroy, 3)
snapshot('ack-applied')
else:
wait(lambda: 'timed out' in compositor_log().split('SB_TREE_CREATE')[-1], 3)
record('timeout_observed')
snapshot('timeout-applied')
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)
command('workspace 1')
command('layout mosaic' if args.mode == 'private_mosaic' else 'layout splith')
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')
spawn(['kitty', '--class', 'REFERENCE', '-o', 'background=#303038',
'/usr/bin/sleep', '900'], 'reference.log')
wait(lambda: bool(find(ipc('-t', 'get_tree'),
lambda n: n.get('type') == 'workspace' and n.get('name') == '2')['nodes']))
command('workspace 1')
time.sleep(.2)
tree = ipc('-t', 'get_tree')
owner = find(tree, lambda n: n.get('app_id') == 'org.mosaic.SavedBufferProbe')
workspace = find(tree, lambda n: n.get('type') == 'workspace' and n.get('name') == '1')
if not owner or not workspace:
raise RuntimeError('owner/workspace calibration failed')
record('identity', owner=owner, workspace=workspace)
preflight_target()
snapshot('reference')
episode('ack', 30)
episode('timeout', 50)
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)