263 lines
9.7 KiB
Python
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)
|