Add terminal VM menu and focused session handoff

This commit is contained in:
Codex
2026-10-13 15:18:03 +03:00
parent b547d6c535
commit c6099103b2
5 changed files with 675 additions and 65 deletions
+28 -9
View File
@@ -130,7 +130,7 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
assert first['pid'] != second['pid'], 'compositor was not refreshed'
logs = {name: root / (name + '.log') for name in ('alpha', 'beta')}
for name, path in logs.items():
wait_for(lambda: path.exists() and 'set_title("HydraOS: ' + name + '")' in path.read_text(),
wait_for(lambda: path.exists() and 'set_title("Hydra OS: ' + name + '")' in path.read_text(),
name + ' product viewer did not connect')
viewers = {name: json.loads((root / (name + '-viewer.json')).read_text()) for name in logs}
assert len({data['config'] for data in viewers.values()}) == 2, 'viewer settings were shared'
@@ -141,10 +141,28 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
assert '--spice-usbredir-redirect-on-connect=-1,-1,-1,-1,0' in data['argv']
alog, blog = logs['alpha'], logs['beta']
display = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock'))
# Exercise a real menu selection of the second, already running VM.
# Its request refreshes only the private compositor/viewers.
before_beta_focus = len(focus_events(blog))
manifest = Path(second['config_directory']).parent / 'session.json'
with (root / 'selection.log').open('w') as selection_log:
selection = subprocess.run(cli + ['_session-helper', 'recover', str(manifest)],
input='beta\n', text=True,
env=dict(env, WAYLAND_DISPLAY=display),
stdout=selection_log, stderr=selection_log, timeout=20)
assert selection.returncode == 0, 'menu selection failed'
wait_for(lambda: (root / 'snapshot-2.json').exists(), 'selected menu did not refresh compositor', timeout=15)
third = json.loads((root / 'snapshot-2.json').read_text())
from xml.etree import ElementTree as ET
assert ET.fromstring((root / 'snapshot-2.xml').read_text()).findtext('./desktops/initial') == '2'
wait_for(lambda: any(p.name.startswith('wayland-') and not p.name.endswith('.lock')
for p in xdg.iterdir()), 'refreshed private display is not ready')
display = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock'))
keyboard_env = dict(env, WAYLAND_DISPLAY=display)
injector = subprocess.Popen([args.keyboard], env=keyboard_env, stdin=subprocess.PIPE, text=True)
time.sleep(.2)
wait_for(lambda: focus_events(alog) and focus_events(alog)[-1] == 'enter', 'alpha did not focus')
wait_for(lambda: len(focus_events(blog)) > before_beta_focus and focus_events(blog)[-1] == 'enter',
'second VM did not gain fresh focus after menu selection')
def switch(code, source_log, target_log):
before_source, before_target = focus_events(source_log), focus_events(target_log)
@@ -153,10 +171,10 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
wait_for(lambda: fresh_focus_transition(before_source, before_target,
focus_events(source_log), focus_events(target_log)), 'fresh reserved focus transition failed')
switch(106, alog, blog)
intervals = {'alpha': measure_hidden_interval(lambda: counter(manager, 'alpha'), alog, blog)}
switch(105, blog, alog)
intervals['beta'] = measure_hidden_interval(lambda: counter(manager, 'beta'), blog, alog)
intervals = {'beta': measure_hidden_interval(lambda: counter(manager, 'beta'), blog, alog)}
switch(106, alog, blog)
intervals['alpha'] = measure_hidden_interval(lambda: counter(manager, 'alpha'), alog, blog)
before = json.loads((root / 'before.json').read_text())
after = {name: {'pid': manager.paths(name).pid.read_text().strip(),
'uuid': manager.paths(name).uuid, 'counter': counter(manager, name)}
@@ -166,15 +184,16 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
assert after[name]['counter'] != before[name]['counter']
# Only the PID recorded by our own wrapper, verified against its private
# config path, may be signalled. Never inherit a host compositor PID.
command = Path('/proc') / str(second['pid']) / 'cmdline'
assert second['config_directory'].encode() in command.read_bytes()
os.kill(second['pid'], signal.SIGTERM)
command = Path('/proc') / str(third['pid']) / 'cmdline'
assert third['config_directory'].encode() in command.read_bytes()
os.kill(third['pid'], signal.SIGTERM)
assert launcher.wait(timeout=10) == 0, 'product session exit failed'
for name in before:
assert manager.status(name)['status'] == 'running'
assert manager.paths(name).pid.read_text().strip() == before[name]['pid']
result.update(initial_names=first['names'], refreshed_names=second['names'],
compositor_pids=[first['pid'], second['pid']], before=before, after=after,
compositor_pids=[first['pid'], second['pid'], third['pid']],
selected_name='beta', selected_workspace=2, before=before, after=after,
viewers=viewers,
hidden_intervals=intervals, survived_session_exit=True,
expected_xwayland_diagnostics=classify_compositor_errors((root / 'session.log').read_text()),