swayfx-mosaic/tooling/probes/step20c-static-runtime-followup/run.py

249 lines
16 KiB
Python

#!/usr/bin/env python3
"""One budgeted follow-up 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, button_pair
p=argparse.ArgumentParser();p.add_argument('lab',type=Path);a=p.parse_args();lab=a.lab.resolve()
assert json.loads((lab/'results/binary-identity.json').read_text())['status']=='PASS'
assert sha(lab/'swayfx-mosaic')=='f82557c1a9978a5e9665b9b4291b74880253725359e3df1c5b8d554921f7603b'
assert (Path.home()/'swayfx-mosaic-static-followup-v1.owner').read_text().strip()==str(lab)
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=[]
started=time.monotonic();finished=threading.Event();expired=threading.Event()
def event(kind,**data):
row={'ns':time.monotonic_ns(),'kind':kind,**data};events.append(row)
with (run/'events.jsonl').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 nodes(node):
yield node
for child in node.get('nodes',[])+node.get('floating_nodes',[]):yield from nodes(child)
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)
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):
rows=[x for x in log('compositor').splitlines() if 'PI_OWNER output=HEADLESS-1 ' in x and f' id={owner["id"]} ' in x]
assert rows,'applied owner trace unavailable'
line=rows[-1];m=re.search(r'scene_valid=1 scene=(-?\d+),(-?\d+) applied_size=(\d+),(\d+)',line);assert m,line
x,y,w,h=map(int,m.groups());win=owner['window_rect']
return {'id':owner['id'],'scene':[x,y,w,h],'window_rect':win,'horizontal_content':[x+win['x'],win['width']],'raw':line,'IPC':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 workspace(tree,name):
return next(n for n in nodes(tree) if n['type']=='workspace' and n['name']==name)
def scene(output):
rows=[s for s in log('compositor').splitlines() if 'PI_SCENE output='+output+' ' in s]
assert rows,'scene trace absent';line=rows[-1]
m=re.search(r'workspace=(\d+) layout=(\d+) scroll=(-?[\d.]+) tile_scene=(-?\d+),(-?\d+) clip_valid=(\d+) clip=(-?\d+),(-?\d+),(\d+),(\d+)',line);assert m,line
v=list(map(float,m.groups()))
return {'workspace':int(v[0]),'layout_enum':int(v[1]),'scroll':v[2],
'tile_local':v[3:5],'clip_valid':bool(v[5]),'clip_local':v[6:10],
'global_clip':[(-800 if output=='HEADLESS-1' else 0)+v[3]+v[6],v[4]+v[7],v[8],v[9]],'raw':line}
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');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'))
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'))
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})
assert scroll_after==scroll_before+delta and sc_after['scroll']==scroll_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)
assert reference['pid']==ref.pid and reference['border']=='none' and reference['current_border_width']==0 and reference['visible']
assert reference in list(nodes(workspace(tree,'2')))
assert reference['rect']=={'x':0,'y':0,'width':800,'height':600}
assert not reference['focused'] and owner['focused']
assert next(z for z in ipc('-t','get_workspaces') if z['focused'])['name']=='1'
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,'clip-observations',{'mosaic':sc_after,'H2':scene('HEADLESS-2')})
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-preflight',{'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'
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)
bx,by,bw,bh=visible['bbox'];assert bx-800==x and bw==800
last_commit=[s for s in log('target').splitlines() if s.startswith('COMMIT ')][-1]
dimensions=re.search(r'size=(\d+)x(\d+)',last_commit);assert dimensions
assert int(dimensions[1])==w and bh-by==int(dimensions[2])
# Paint proves vertical position; only horizontal continuation is inferred
# from the applied scene, IPC offset and actual configured client width.
px,py=interior(left);permitted=(px-800,py)
forbidden_point=(min(100,int(x+w)-30),py);assert forbidden_point[0]>=20
assert x<forbidden_point[0]<x+w and right.getpixel(forbidden_point)==(48,48,56)
save(lab,'click-points',{'permitted':permitted,'forbidden':forbidden_point,'paint_bbox':visible['bbox'],'horizontal_content':[x,w]})
click=measured_click('visible_click',permitted);events_visible=button_pair(click['events']['target'])
assert not click['events']['reference'] and not click['events']['filler']
save(lab,'target-input',{'status':'PASS visible','point':permitted,'events':events_visible})
tree=snapshot('after-visible');assert camera(tree)==scroll_after
assert measure(find('org.mosaic.SavedBufferProbe'))['horizontal_content']==[x,w]
click=measured_click('forbidden_click',forbidden_point);events_reference=button_pair(click['events']['reference'])
assert not click['events']['target'] and not click['events']['filler'],'invisible target or wrong client input'
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']
inject('move',-790,590);time.sleep(.6)
whole('post-click-H2-comparison',baseline,capture('h2-after-clicks','HEADLESS-2'))
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')
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=button_pair(click['events']['target'])
assert not click['events']['reference'] and not click['events']['filler']
snapshot('after-standard-click');inject('move',-790,590);time.sleep(.6)
whole('final-H2-comparison',baseline,capture('h2-standard-after-click','HEADLESS-2'))
save(lab,'standard-return',{'status':'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])
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,'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_FOLLOWUP',failure or 'PASS')
if failure:raise SystemExit(1)