swayfx-mosaic/tooling/probes/step20c-static-rgb-input-retest/run.py

258 lines
16 KiB
Python

#!/usr/bin/env python3
"""One independent budgeted RGB/input/standard retest of the unchanged verified static compositor.
WHY: resolved relocations do not demonstrate real compositor execution.
CONTRACT: verify /proc executable/library identity before mapping any client.
INVARIANT: owned sockets/groups only, one camera correction, durable captures.
"""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
import tempfile
import threading
import time
from PIL import Image,ImageChops
from support import environment,reserve,save,sha
from pixels import analyze, compare, interior, painted_geometry, click_points, delivery
from ipc_predicates import (nodes, workspace, validate_outputs, validate_reference,
validate_preflight, measure_owner, scene_from_log, client_commit, validate_camera)
from verify import verify_inventory
p=argparse.ArgumentParser();p.add_argument('lab',type=Path);a=p.parse_args();lab=a.lab.resolve()
assert not __import__('sys').flags.optimize, 'Python assertions must be enabled'
assert json.loads((lab/'results/binary-identity.json').read_text())['status']=='PASS'
assert sha(lab/'swayfx-mosaic')=='f82557c1a9978a5e9665b9b4291b74880253725359e3df1c5b8d554921f7603b'
assert (Path.home()/'swayfx-mosaic-static-rgb-input-retest-v1.owner').read_text().strip()==str(lab)
# CONTRACT: run reservation is impossible until the exact frozen fixture passes.
assert not __import__('sys').flags.optimize, 'Python assertions must be enabled'
verify_inventory(lab/'results/fixture-freeze-sha256.txt')
gate=json.loads((lab/'results/offline-gate.json').read_text())
assert gate=={'reference':'PASS','preflight':'PASS','AST':'PASS','ledger':'PASS','source_audit':'PASS'}
assert json.loads((lab/'results/ipc-replay.json').read_text())['marker']=='STATIC_REFERENCE_IPC_REPLAY=PASS'
assert json.loads((lab/'results/preflight-replay.json').read_text())['marker']=='STATIC_FIXTURE_PREFLIGHT_REPLAY=PASS'
reserve(lab,'run');run=lab/'run';run.mkdir();runtime=Path(tempfile.mkdtemp(prefix='msr-',dir='/tmp'))
runtime.chmod(0o700);(run/'runtime-path').write_text(str(runtime)+'\n')
(run/'config').write_text('output HEADLESS-1 resolution 800x600 scale 1 transform normal position -800 0\noutput HEADLESS-2 resolution 800x600 scale 1 transform normal position 0 0\nworkspace 1 output HEADLESS-1\nworkspace 2 output HEADLESS-2\nfocus_follows_mouse no\nseat seat0 hide_cursor 100\n')
env=environment();env.update(XDG_RUNTIME_DIR=str(runtime),WLR_BACKENDS='headless',WLR_HEADLESS_OUTPUTS='2',WLR_RENDERER='pixman')
binary=lab/'swayfx-mosaic';processes=[];files=[];failure=None;compositor=None;events=[];stage='startup'
started=time.monotonic();finished=threading.Event();expired=threading.Event()
def event(kind,**data):
row={'ns':time.monotonic_ns(),'kind':kind,**data}
path=run/'events.jsonl'
assert not path.exists() or path.stat().st_size<64*1024*1024, 'event output bound'
with path.open('a') as out:out.write(json.dumps(row)+'\n')
def stop_owned():
for proc in list(processes):
try:os.killpg(proc.pid,signal.SIGTERM)
except ProcessLookupError:pass
def watchdog():
while not finished.wait(.1):
if time.monotonic()-started>150 or any(x.stat().st_size>64*1024*1024 for x in run.glob('*.log')):
expired.set();stop_owned();return
thread=threading.Thread(target=watchdog,daemon=True);thread.start()
def spawn(name,argv,stdin=False,extra=None):
assert not expired.is_set()
out=(run/(name+'.log')).open('w');files.append(out)
proc=subprocess.Popen(list(map(str,argv)),env={**env,**(extra or {})},stdin=subprocess.PIPE if stdin else subprocess.DEVNULL,stdout=out,stderr=subprocess.STDOUT,text=True,start_new_session=True)
processes.append(proc);event('spawn',name=name,pid=proc.pid,argv=list(map(str,argv)));return proc
def wait(predicate,seconds=8):
end=time.monotonic()+seconds
while time.monotonic()<end:
if expired.is_set():raise RuntimeError('deadline/log bound exceeded')
if compositor and compositor.poll() is not None:raise RuntimeError('compositor exited '+str(compositor.returncode))
value=predicate()
if value:return value
time.sleep(.025)
raise RuntimeError('fixture wait timed out')
def log(name):return (run/(name+'.log')).read_text(errors='replace')
def ipc(*args):
assert sock.is_relative_to(runtime) and sock.is_socket()
result=subprocess.run(['swaymsg','-s',str(sock),*args],env=env,capture_output=True,text=True,timeout=5,check=True)
data=json.loads(result.stdout);event('ipc',args=args,result=data);return data
def cmd(value):
data=ipc(value);assert all(x.get('success') for x in data),(value,data)
def find(app):return next((n for n in nodes(ipc('-t','get_tree')) if n.get('app_id')==app),None)
def inject(action,x,y,kind='move'):
reserve(lab,kind)
if kind!='move':reserve(lab,'move') # Clicks also contain one actual pointer motion.
assert -800<=x<800 and 0<=y<600
marker=f'INJECT_DONE {action} {x+800},{y}';before=log('pointer').count(marker)
pointer.stdin.write(f'{action} {x+800} {y}\n');pointer.stdin.flush()
wait(lambda:log('pointer').count(marker)>before)
time.sleep(.15);event('pointer',action=action,global_point=[x,y],target_tail=log('target').splitlines()[-6:] if (run/'target.log').exists() else [])
def capture(tag,output):
reserve(lab,'capture');path=run/(tag+'.png')
subprocess.run(['grim','-s','1','-o',output,str(path)],env=env,check=True,timeout=5,stdout=subprocess.DEVNULL)
im=Image.open(path).convert('RGB');assert im.size==(800,600)
event('capture',file=path.name,output=output,sha256=sha(path));return im
def measure(owner):return measure_owner(log('compositor'),owner)
# WHY: global focus becomes H2 after the forbidden-region click. Workspace and
# owner IDs, rather than global command context, bind the standard-return test.
focus_states=[]
def snapshot(tag):
tree=ipc('-t','get_tree');outputs=ipc('-t','get_outputs');workspaces=ipc('-t','get_workspaces')
row={'tag':tag,'ns':time.monotonic_ns(),'tree':tree,'outputs':outputs,'workspaces':workspaces,
'focused':[n['id'] for n in nodes(tree) if n.get('focused')]}
focus_states.append(row);save(lab,'focus-states',focus_states)
save(lab,tag+'-snapshot',row)
return tree
def scene(output):return scene_from_log(log('compositor'),output)
def camera(tree):return workspace(tree,'1')['mosaic']['rows'][0]['scroll_x']
def buttons(name):return [x for x in log(name).splitlines() if x.startswith('BUTTON ')]
def measured_click(kind,point):
before={n:len(buttons(n)) for n in ['target','reference','filler']}
inject('right',*point,kind=kind)
row={'global_point':point,'events':{n:buttons(n)[before[n]:] for n in before},
'tails':{n:log(n).splitlines()[-15:] for n in before},'ns':time.monotonic_ns()}
save(lab,kind,row);return row
def whole(tag,baseline,im):
value=compare(baseline,im);save(lab,tag,value);assert value['equal'],'whole H2 differs: '+tag
return value
try:
compositor=spawn('compositor',[binary,'-d','-c',run/'config'])
sock=wait(lambda:next(iter(runtime.glob('sway-ipc.*.sock')),None))
display=wait(lambda:next((x for x in runtime.glob('wayland-*') if x.is_socket()),None))
env.update(SWAYSOCK=str(sock),WAYLAND_DISPLAY=display.name)
maps=Path(f'/proc/{compositor.pid}/maps').read_text();(run/'proc-maps.txt').write_text(maps)
exe=Path(f'/proc/{compositor.pid}/exe').resolve();assert exe==binary and sha(exe)==sha(binary)
paths=sorted({line.split()[-1] for line in maps.splitlines() if len(line.split())>=6 and line.split()[-1].startswith('/')})
wl=Path('/usr/lib/libwlroots-0.20.so').resolve()
assert not any('libscenefx' in x for x in paths)
assert [Path(x).resolve() for x in paths if 'libwlroots' in x]==[wl]
assert sha(wl)=='7edca9a7d65855addda68e29ba5db94d57c803eb5fa7d0f86db3136b90ca9837'
assert not any('/swayfx-mosaic-' in x and Path(x)!=binary for x in paths)
save(lab,'dependency-mappings',{'status':'PASS','exe':str(exe),'paths':paths,'wlroots_sha256':sha(wl),'SceneFX_shared':False})
outputs=ipc('-t','get_outputs');validate_outputs(outputs);actual={o['name']:(o['rect']['x'],o['rect']['y'],o['rect']['width'],o['rect']['height'],o['scale'],o['transform']) for o in outputs if o['active']}
assert actual=={'HEADLESS-1':(-800,0,800,600,1.0,'normal'),'HEADLESS-2':(0,0,800,600,1.0,'normal')},actual
save(lab,'output-geometry',outputs)
pointer=spawn('pointer',[lab/'pointer-daemon','1600','600'],stdin=True);wait(lambda:'POINTER_READY' in log('pointer'))
stage='clients'
cmd('workspace 2');ref=spawn('reference',[lab/'static-reference'],stdin=True,extra={'WAYLAND_DEBUG':'1'})
reference=wait(lambda:find('org.mosaic.StaticReference'));refid=reference['id']
cmd(f'[con_id={refid}] border none')
cmd('workspace 1');filler=spawn('filler',[lab/'static-reference'],stdin=True)
wait(lambda:len([n for n in nodes(ipc('-t','get_tree')) if n.get('app_id')=='org.mosaic.StaticReference'])==2)
target=spawn('target',[lab/'saved-client'],stdin=True,extra={'WAYLAND_DEBUG':'1'})
wait(lambda:find('org.mosaic.SavedBufferProbe'))
stage='preflight'
cmd('layout mosaic');time.sleep(.6)
tree=snapshot('before-camera');owner=find('org.mosaic.SavedBufferProbe');targetid=owner['id'];wsid=workspace(tree,'1')['id']
before=measure(owner);sc_before=scene('HEADLESS-1');scroll_before=camera(tree)
assert sc_before['scroll']==scroll_before
x,w=before['horizontal_content'];desired=-w//2;delta=x-desired
reserve(lab,'camera');cmd(f'layout mosaic scroll {delta}')
time.sleep(.6);owner=find('org.mosaic.SavedBufferProbe');after=measure(owner)
tree=snapshot('preflight');scroll_after=camera(tree);sc_after=scene('HEADLESS-1')
save(lab,'camera-calibration',{'before':scroll_before,'delta':delta,'desired_left':desired,'after':scroll_after,'before_scene':sc_before,'after_scene':sc_after})
validate_camera(scroll_before,scroll_after,delta,sc_after)
x,w=after['horizontal_content'];assert x<0<x+w and -800<x
assert sc_after['clip_valid'] and sc_after['global_clip']==[-800,0,800,600]
assert sc_after['workspace']==wsid
reference=next(n for n in nodes(tree) if n['id']==refid)
commit=client_commit(log('target'))
preflight=validate_preflight(focus_states[-1],refid,ref.pid,targetid,target.pid,
sc_after,scene('HEADLESS-2'),after,commit)
save(lab,'reference-state',preflight['reference'])
save(lab,'live-preflight',preflight)
save(lab,'owner-geometry',{'before':before,'after':after,'reference':reference,'last_client_commit':[s for s in log('target').splitlines() if s.startswith('COMMIT ')][-1]})
save(lab,'scene-clips',{'mosaic':sc_after,'H2':scene('HEADLESS-2')})
stage='reference'
inject('move',-790,590);time.sleep(.6)
baseline=capture('h2-reference-1','HEADLESS-2');time.sleep(.6);second=capture('h2-reference-2','HEADLESS-2')
comparison=compare(baseline,second)
save(lab,'reference-stability',{'H2_REFERENCE_STABLE':comparison['equal'],'H2_WHOLE_RGB_EQUAL':comparison['equal'],'reference':reference,'focus':focus_states[-1]['focused'],'hashes':{n:sha(run/n) for n in ['h2-reference-1.png','h2-reference-2.png']}})
save(lab,'reference-pixel-analysis',comparison)
assert comparison['equal'] and all(c==(48,48,56) for c in baseline.get_flattened_data()),'reference preflight failed'
stage='mosaic'
left=capture('h1-mosaic','HEADLESS-1');right=capture('h2-mosaic','HEADLESS-2')
visible=analyze(left);forbidden=analyze(right)
save(lab,'permitted-pixels',visible);save(lab,'forbidden-pixels',forbidden)
assert visible['count']>0 and forbidden['count']==0
whole('v1-H2-comparison',baseline,right)
paint=painted_geometry(left,after,commit)
points=click_points(left,right,after)
permitted=points['permitted'];forbidden_point=points['forbidden']
save(lab,'painted-geometry',{'paint':paint,'measured':after,'commit':commit,'points':points})
save(lab,'pixel-analysis',{'STATIC_MOSAIC_VISIBLE_PIXELS':'PASS',
'STATIC_MOSAIC_FORBIDDEN_PIXELS':forbidden['count'],'STATIC_MOSAIC_H2_WHOLE_RGB':'PASS',
'H1':visible,'H2':forbidden})
save(lab,'click-points',{'permitted':permitted,'forbidden':forbidden_point,'paint_bbox':visible['bbox'],'horizontal_content':[x,w]})
stage='visible-input'
click=measured_click('visible_click',permitted);events_visible=delivery(click['events'],'target')
assert not click['events']['reference'] and not click['events']['filler']
save(lab,'visible-input',{'status':'PASS','point':permitted,'events':events_visible})
tree=snapshot('after-visible');assert camera(tree)==scroll_after
assert measure(find('org.mosaic.SavedBufferProbe'))['horizontal_content']==[x,w]
stage='forbidden-input'
click=measured_click('forbidden_click',forbidden_point);events_reference=delivery(click['events'],'reference')
assert not click['events']['target'] and not click['events']['filler'],'invisible target or wrong client input'
save(lab,'forbidden-input',{'status':'PASS','target_received':False})
save(lab,'reference-input',{'status':'PASS','point':forbidden_point,'events':events_reference,'invisible_target_events':click['events']['target']})
tree=snapshot('after-reference');assert camera(tree)==scroll_after
assert next(n for n in nodes(tree) if n['id']==refid)['focused']
save(lab,'reference-after-focus',validate_reference(focus_states[-1],refid,ref.pid))
inject('move',-790,590);time.sleep(.6)
whole('post-click-H2-comparison',baseline,capture('h2-after-clicks','HEADLESS-2'))
stage='standard-return'
cmd(f'[con_id={targetid}] layout splith');time.sleep(.6)
tree=snapshot('standard');standard_ws=workspace(tree,'1');assert standard_ws['id']==wsid and standard_ws['layout']=='splith'
standard_owner=next(n for n in nodes(standard_ws) if n['id']==targetid);assert standard_owner['visible']
standard_left=capture('h1-standard','HEADLESS-1');standard_right=capture('h2-standard','HEADLESS-2')
save(lab,'standard-pixels',{'H1':analyze(standard_left),'H2':analyze(standard_right)})
assert analyze(standard_right)['count']==0, 'standard target leaked to H2'
whole('standard-H2-comparison',baseline,standard_right)
standard_scene=scene('HEADLESS-1');standard_h2=scene('HEADLESS-2')
assert standard_scene['workspace']==wsid and not standard_scene['clip_valid'] and not standard_h2['clip_valid']
sx,sy=interior(standard_left);point=(sx-800,sy)
click=measured_click('standard_click',point);events_standard=delivery(click['events'],'target')
assert not click['events']['reference'] and not click['events']['filler']
final_tree=snapshot('after-standard-click')
assert next(n for n in nodes(final_tree) if n['id']==targetid)['focused']
assert [z['name'] for z in focus_states[-1]['workspaces'] if z['focused']]==['1']
save(lab,'reference-after-standard',validate_reference(focus_states[-1],refid,ref.pid))
inject('move',-790,590);time.sleep(.6)
whole('final-H2-comparison',baseline,capture('h2-standard-after-click','HEADLESS-2'))
save(lab,'standard-return',{'status':'STATIC_STANDARD_RETURN=PASS','workspace':standard_ws,'owner':measure(standard_owner),'scene':standard_scene,'H2_scene':standard_h2,'point':point,'events':events_standard,'pixels':analyze(standard_left),'H2_equal':True})
assert all(p.poll() is None for p in [target,ref,filler,pointer])
stage='completed'
cmd('exit');compositor.wait(timeout=5);assert compositor.returncode==0
except Exception as exc:
failure=f'{type(exc).__name__}: {exc}';event('failure',message=failure)
finally:
finished.set();thread.join(timeout=1);stop_owned()
for proc in processes:
try:proc.wait(timeout=3)
except subprocess.TimeoutExpired:
os.killpg(proc.pid,signal.SIGKILL);proc.wait(timeout=3)
try:os.killpg(proc.pid,signal.SIGKILL)
except ProcessLookupError:pass
for out in files:out.close()
shutil.rmtree(runtime)
save(lab,'runtime',{'status':'PASS' if failure is None else 'INCONCLUSIVE','failure':failure,'stopping_stage':stage,'duration_seconds':time.monotonic()-started,'process_exit_codes':[{'pid':x.pid,'argv':x.args,'exit':x.returncode} for x in processes],'runtime_removed':str(runtime),'cleanup':'owned groups reaped, runtime removed'})
damage=[s for s in log('compositor').splitlines() if 'TEMPORAL_DAMAGE ' in s] if (run/'compositor.log').exists() else []
save(lab,'damage-analysis',{'extents':damage,'all_frames':'NOT TESTED','PNG_native_buffer_identity':'NOT PROVEN','client_damage':'wl_surface_damage_buffer full SHM at actual commits; see client source/protocol logs'})
print('STATIC_RGB_INPUT_STANDARD',failure or 'PASS')
if failure:raise SystemExit(1)