258 lines
16 KiB
Python
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)
|