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

258 lines
10 KiB
Python

"""One reserved headless run with a real held XDG configure per episode."""
import argparse
import json
import os
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')
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')):
if captures >= 20:
raise RuntimeError('capture budget exceeded')
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 episode(kind, gaps):
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:])
snapshot(f'{kind}-held')
# The private-standard ACK episode exhausted its 1200 ms window during
# six pointer commands. Preserve that incident and prioritize the actual
# Mosaic snapshot lifetime; no unmeasured input claim follows.
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=1200'], 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)
if args.mode != 'control':
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', '/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')
if args.mode == 'private_mosaic':
command('layout mosaic scroll 400')
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)
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)