swayfx-mosaic/tooling/probes/step20c-nested-grab/run.py

263 lines
9.7 KiB
Python

"""EXPERIMENTAL isolated two-output nested XDG grab runner.
Usage: python run.py LAB {control,private_standard,private_mosaic} SCENARIO PLACEMENT
SCENARIO: open, visible_click, crossing, close_b, outside, hidden, hidden_click, owner, toggle
PLACEMENT: visible, b_cross, both_cross
"""
import json
import os
import pathlib
import shutil
import signal
import subprocess
import sys
import tempfile
import time
LAB = pathlib.Path(sys.argv[1]).resolve()
case, scenario, placement = sys.argv[2:5]
assert case in ('control', 'private_standard', 'private_mosaic')
assert scenario in ('open', 'visible_click', 'crossing', 'close_b', 'outside', 'hidden',
'hidden_click', 'owner', 'toggle')
assert placement in ('visible', 'b_cross', 'both_cross')
assert LAB.is_dir() and (LAB / 'nested-client').is_file()
run = LAB / f'run-{case}-{scenario}-{placement}'
run.mkdir(mode=0o700, exist_ok=False)
runtime = pathlib.Path(tempfile.mkdtemp(prefix='mng-', dir='/tmp'))
# WHY: keep both Unix socket names below sockaddr_un.sun_path even if LAB is
# long; the compositor derives its IPC path from XDG_RUNTIME_DIR.
if len(os.fsencode(str(runtime / 'sway-ipc.4294967295.2147483647.sock'))) >= 108:
raise RuntimeError('private IPC socket path exceeds Linux sockaddr_un.sun_path')
(run / 'runtime-path').write_text(str(runtime) + '\n')
(run / 'config').write_text(
'output HEADLESS-1 resolution 800x600 position 0 0\n'
'output HEADLESS-2 resolution 800x600 position 800 0\n'
'workspace 1 output HEADLESS-1\nworkspace 2 output HEADLESS-2\n'
'focus_follows_mouse no\n')
env = os.environ.copy()
env.update(XDG_RUNTIME_DIR=str(runtime), WLR_BACKENDS='headless',
WLR_HEADLESS_OUTPUTS='2', WLR_RENDERER='pixman')
env.pop('WAYLAND_DISPLAY', None)
env.pop('SWAYSOCK', None)
env.pop('I3SOCK', None)
env.pop('LD_LIBRARY_PATH', None)
compositor_env = env.copy()
if case == 'control':
binary = pathlib.Path('/usr/bin/sway')
else:
binary = LAB / 'mosaic-build/sway/swayfx-mosaic'
compositor_env['LD_LIBRARY_PATH'] = str(LAB / 'scenefx-build')
files = []
children = []
def spawn(argv, name, *, debug=False, stdin=None):
log = (run / name).open('w')
files.append(log)
child_env = env.copy()
if debug:
child_env['WAYLAND_DEBUG'] = '1'
proc = subprocess.Popen(argv, env=child_env, stdin=stdin, stdout=log,
stderr=subprocess.STDOUT, text=True,
start_new_session=True)
children.append(proc)
return proc
def wait(fn, seconds=12):
end = time.monotonic() + seconds
while time.monotonic() < end:
result = fn()
if result:
return result
if compositor.poll() is not None:
raise RuntimeError(f'compositor exited {compositor.returncode}')
time.sleep(.05)
raise RuntimeError('timeout waiting for probe event')
def ipc(*args):
return json.loads(subprocess.run(['swaymsg', '-s', str(sock), *args],
env=env, text=True, capture_output=True, check=True,
timeout=5).stdout)
def find(node, predicate):
if isinstance(node, dict):
if predicate(node):
return node
for child in node.get('nodes', []) + node.get('floating_nodes', []):
result = find(child, predicate)
if result:
return result
return None
def tree():
return ipc('-t', 'get_tree')
def ws():
return find(tree(), lambda n: n.get('type') == 'workspace' and n.get('name') == '1')
def owner():
return find(tree(), lambda n: n.get('app_id') == 'org.mosaic.NestedGrabProbe')
def logged(s):
path = run / 'client.log'
return path.exists() and s in path.read_text()
def capture(tag):
(run / f'tree-{tag}.json').write_text(json.dumps(tree(), indent=2))
(run / f'outputs-{tag}.json').write_text(json.dumps(ipc('-t', 'get_outputs'), indent=2))
for output, label in (('HEADLESS-1', 'left'), ('HEADLESS-2', 'right')):
subprocess.run(['grim', '-o', output, str(run / f'{label}-{tag}.png')],
env=env, check=True, timeout=8, stdout=subprocess.DEVNULL)
def inject(action, x, y):
marker = f'INJECT_DONE {action} {x},{y}'
pointer.stdin.write(f'{action} {x} {y}\n')
pointer.stdin.flush()
wait(lambda: marker in (run / 'pointer.log').read_text())
def command(value):
client.stdin.write(value + '\n')
client.stdin.flush()
wait(lambda: f'COMMAND {value}' in (run / 'client.log').read_text())
compositor_log = (run / 'compositor.log').open('w')
files.append(compositor_log)
compositor = None
try:
compositor = subprocess.Popen([str(binary), '-c', str(run / 'config'), '-d'],
env=compositor_env, stdout=compositor_log,
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)
pointer = spawn([str(LAB / 'pointer-daemon')], 'pointer.log',
stdin=subprocess.PIPE)
wait(lambda: 'POINTER_READY' in (run / 'pointer.log').read_text())
ipc('workspace 1')
ipc('layout mosaic' if case == 'private_mosaic' else 'layout splith')
spawn(['kitty', '--class', 'A', '-o', 'background=#aa2222',
'/usr/bin/sleep', '90'], 'A.log')
wait(lambda: len(ws()['nodes']) >= 1)
client = spawn([str(LAB / 'nested-client'), placement], 'client.log',
debug=True, stdin=subprocess.PIPE)
wait(lambda: owner() and logged('TOPLEVEL MAP'))
for label in ('C', 'D'):
spawn(['kitty', '--class', label, '/usr/bin/sleep', '90'], label + '.log')
time.sleep(.35)
ipc('workspace 2')
spawn(['stdbuf', '-oL', 'wev'], 'wev.log')
time.sleep(.5)
ipc('workspace 1')
ipc('[app_id="org.mosaic.NestedGrabProbe"] focus')
if case == 'private_mosaic':
current = ws()['mosaic']['rows'][0]['scroll_x']
ipc(f'layout mosaic scroll {400-current:g}')
time.sleep(.4)
o = owner()
scroll = ws()['mosaic']['rows'][0]['scroll_x'] if case == 'private_mosaic' else 0
rendered_x = o['rect']['x'] - scroll
top_x = int(max(40, min(760, rendered_x + 100)))
inject('left', top_x, 150)
wait(lambda: logged('POPUP_A MAP'))
anchor_a = {'visible': 50, 'b_cross': 200, 'both_cross': 350}[placement]
a_x = int(max(40, min(760, rendered_x + anchor_a + 40)))
inject('left', a_x, 150)
wait(lambda: logged('POPUP_B MAP'))
time.sleep(.3)
capture('open')
print('CHAIN_OPEN', case, scenario, placement, 'owner_id', o['id'],
'owner_rect', o['rect'], 'scroll', scroll,
'top_click', top_x, 'a_click', a_x,
'client_alive', client.poll() is None, flush=True)
if scenario == 'visible_click':
anchor_b = 20
b_x = int(max(40, min(760, rendered_x + anchor_a + anchor_b + 40)))
inject('right', b_x, 190)
time.sleep(.2)
capture('child-click')
print('B_CLICK_POINT', b_x, 190, flush=True)
elif scenario == 'crossing':
actions = [('move', 700, 190), ('right', 700, 190)]
if placement == 'both_cross':
actions += [('move', 780, 190), ('right', 780, 190)]
actions += [('move', 900, 190), ('right', 900, 190)]
for action, x, y in actions:
inject(action, x, y)
time.sleep(.2)
capture(f'{action}-{x}')
elif scenario == 'close_b':
command('close_b')
time.sleep(.3)
capture('closed-b')
inject('right', a_x, 150)
time.sleep(.25)
capture('parent-click')
elif scenario == 'outside':
inject('left', 900, 400)
time.sleep(.4)
capture('outside')
elif scenario in ('hidden', 'hidden_click'):
assert case == 'private_mosaic'
ipc('layout mosaic scroll 880')
time.sleep(.5)
capture('hidden')
print('HIDDEN_STATE', ws()['mosaic'], 'owner_id', owner()['id'],
'a_done', logged('POPUP_A POPUP_DONE'),
'b_done', logged('POPUP_B POPUP_DONE'), flush=True)
inject('move', 100, 190)
time.sleep(.2)
capture('hidden-hover')
if scenario == 'hidden_click':
inject('right', 100, 190)
time.sleep(.2)
capture('hidden-click')
ipc('layout mosaic scroll -880')
time.sleep(.4)
capture('returned')
elif scenario == 'owner':
ipc(f'[con_id={o["id"]}] kill')
time.sleep(.4)
capture('owner-destroyed')
elif scenario == 'toggle':
assert case == 'private_mosaic'
ipc('layout splith')
time.sleep(.4)
capture('standard')
inject('right', 900, 190)
time.sleep(.2)
capture('standard-click')
print('FINAL', 'a_done', logged('POPUP_A POPUP_DONE'),
'b_done', logged('POPUP_B POPUP_DONE'),
'client_alive', client.poll() is None, flush=True)
finally:
for child in children:
if child.poll() is None:
os.killpg(child.pid, signal.SIGTERM)
if compositor is not None and compositor.poll() is None:
os.killpg(compositor.pid, signal.SIGTERM)
# CONTRACT: only process groups started by this runner are terminated.
# Reap them before removing their private socket directory.
for proc in [*children, *([compositor] if compositor is not None else [])]:
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait(timeout=5)
for file in files:
file.close()
shutil.rmtree(runtime)