From 664c995f84d80a494bcf84716deca4b74691a06b Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 04:35:14 +0300 Subject: [PATCH] test: verify independent guests and recovery on private displays --- README.md | 22 +++ dev/build-input-probe.sh | 8 ++ dev/check-edid.py | 98 +++++++++++++ dev/guest-probe.S | 25 ++++ dev/input-probe.c | 63 +++++++++ dev/smoke.py | 202 +++++++++++++++++++++------ dev/tools.nix | 4 +- dev/virtual-keyboard-unstable-v1.xml | 113 +++++++++++++++ docs/verification.md | 112 +++++++++++++++ tests/test_profiles.py | 5 + tests/test_session.py | 3 +- 11 files changed, 608 insertions(+), 47 deletions(-) create mode 100755 dev/build-input-probe.sh create mode 100644 dev/check-edid.py create mode 100644 dev/guest-probe.S create mode 100644 dev/input-probe.c create mode 100644 dev/virtual-keyboard-unstable-v1.xml create mode 100644 docs/verification.md diff --git a/README.md b/README.md index 647765f..30ebd69 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,25 @@ a refresh-rate setting does not prove delivered FPS. - [Hardware target](docs/hardware/asus-fx517zc.md) Build outputs, VM disks, runtime state and credentials stay outside Git. + +## Run the prototype + +```sh +python3 -m unittest discover -s tests -v +nix-shell dev/tools.nix +bin/hydra add guest --iso /absolute/path/installer.iso +bin/hydra start guest +bin/hydra view guest +# From a dedicated graphical login; --nested uses the current graphical display. +bin/hydra session --nested +``` + +After installation, shut the guest down and run `bin/hydra media guest --detach` +to boot its existing disk without installation media. `add` also supports disk-only +profiles, `--width`, `--height`, and `--refresh-millihz`. Distribution preferences +in `/etc/hydra/defaults.toml` affect only new profiles; explicit CLI options win. + +`nix-shell dev/tools.nix --run 'python3 dev/smoke.py'` checks two disposable KVM +counter guests and private headless viewers, including manual USB selection, +reserved switching under inhibition, recovery and reconnect. See +[verification commands and physical checks still pending](docs/verification.md). diff --git a/dev/build-input-probe.sh b/dev/build-input-probe.sh new file mode 100755 index 0000000..2717ca6 --- /dev/null +++ b/dev/build-input-probe.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu +source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +build_dir=$(mktemp -d /tmp/hydra-input-build-XXXXXX) +wayland-scanner client-header "$source_dir/virtual-keyboard-unstable-v1.xml" "$build_dir/virtual-keyboard-client-protocol.h" +wayland-scanner private-code "$source_dir/virtual-keyboard-unstable-v1.xml" "$build_dir/virtual-keyboard-protocol.c" +cc -Wall -Wextra -Werror "$source_dir/input-probe.c" "$build_dir/virtual-keyboard-protocol.c" -I"$build_dir" $(pkg-config --cflags --libs wayland-client xkbcommon) -o "$build_dir/input-probe" +printf '%s\n' "$build_dir/input-probe" diff --git a/dev/check-edid.py b/dev/check-edid.py new file mode 100644 index 0000000..01947d7 --- /dev/null +++ b/dev/check-edid.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Compile actual QEMU EDID code and decode the guest-visible response. + +Catches loss of the configured refresh rate, a frontend overriding it, and +monitor ranges excluding the generated preferred mode. No VM is touched. +""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import tarfile + +source_root = Path(sys.argv[1]) +# Accept the pinned QEMU source archive directly. Read only the four files +# needed here, avoiding unrelated upstream absolute symlinks in rom sources. +if source_root.is_file(): + extracted = tempfile.TemporaryDirectory(prefix='hydra-edid-source-') + with tarfile.open(source_root) as archive: + members = {name: next(member for member in archive.getmembers() + if member.name.endswith('/' + name)) for name in ( + 'hw/display/edid-generate.c', 'include/hw/display/edid.h', + 'include/hw/virtio/virtio-gpu.h', 'hw/display/virtio-gpu-base.c')} + source_root = Path(extracted.name) + for name, member in members.items(): + destination = source_root / name + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(archive.extractfile(member).read()) + subprocess.run(['patch', '--batch', '-p1', '-i', + str(Path(__file__).resolve().parents[1] / 'patches/qemu-virtio-refresh-rate.patch')], + cwd=source_root, check=True) +edid = (source_root / 'hw/display/edid-generate.c').read_text() +edid = '\n'.join(line for line in edid.splitlines() if not line.startswith('#include')) +header = (source_root / 'include/hw/display/edid.h').read_text() +header = header[header.index('typedef struct qemu_edid_info'):header.index('void qemu_edid_region_io')] +gpu = (source_root / 'hw/display/virtio-gpu-base.c').read_text() +start = gpu.index('void\nvirtio_gpu_base_generate_edid') +gpu = gpu[start:gpu.index('static void virtio_gpu_invalidate_display', start)] +stubs = r''' +#include +#include +#include +#include +#include +#define ARRAY_SIZE(x) (sizeof(x)/sizeof((x)[0])) +#define MAX(a,b) ((a) > (b) ? (a) : (b)) +#define MIN(a,b) ((a) < (b) ? (a) : (b)) +#define DIV_ROUND_UP(n,d) (((n) + (d) - 1) / (d)) +#define cpu_to_le32(x) (x) +static void stw_le_p(void *p,uint16_t v) { uint8_t *b=p; b[0]=v; b[1]=v>>8; } +static void stw_be_p(void *p,uint16_t v) { uint8_t *b=p; b[1]=v; b[0]=v>>8; } +static void stl_le_p(void *p,uint32_t v) { uint8_t *b=p; for(int i=0;i<4;i++) b[i]=v>>(8*i); } +typedef struct VirtIOGPUOutput { const char *name; } VirtIOGPUOutput; +typedef struct VirtIOGPUOutputList { struct VirtIOGPUOutputList *next; VirtIOGPUOutput *value; } VirtIOGPUOutputList; +struct virtio_gpu_requested_state { uint16_t width_mm,height_mm; uint32_t width,height,refresh_rate; int x,y; }; +typedef struct VirtIOGPUBase { struct { uint32_t refresh_rate; VirtIOGPUOutputList *outputs; } conf; struct virtio_gpu_requested_state req_state[1]; } VirtIOGPUBase; +struct virtio_gpu_resp_edid { uint32_t size; uint8_t edid[1024]; }; +''' +main = r''' +int main(int argc,char **argv) { + VirtIOGPUBase gpu = {0}; struct virtio_gpu_resp_edid out = {0}; + gpu.conf.refresh_rate=atoi(argv[1]); gpu.req_state[0].refresh_rate=atoi(argv[2]); + gpu.req_state[0].width=atoi(argv[3]); gpu.req_state[0].height=atoi(argv[4]); + virtio_gpu_base_generate_edid(&gpu,0,&out); + fwrite(out.edid,1,qemu_edid_size(out.edid),stdout); return 0; +} +''' +compiler = os.environ.get('CC', 'cc') +with tempfile.TemporaryDirectory(prefix='qemu-edid-probe-') as temporary: + directory = Path(temporary) + (directory / 'probe.c').write_text(stubs + header + edid + gpu + main) + subprocess.run([compiler, '-O2', str(directory / 'probe.c'), '-o', str(directory / 'probe')], check=True) + for configured, frontend, width, height, expected in [ + (0, 0, 1920, 1080, 75), + (0, 60000, 1920, 1080, 60), + (144000, 0, 1920, 1080, 144), + (144000, 60000, 1920, 1080, 144), + (144000, 0, 1600, 900, 144), + (0, 144000, 1920, 1080, 144), + ]: + data = subprocess.check_output([str(directory / 'probe'), *map(str, (configured, frontend, width, height))]) + assert all(sum(data[offset:offset + 128]) % 256 == 0 for offset in range(0, len(data), 128)), 'invalid EDID checksum' + timing = data[54:72] + clock = int.from_bytes(timing[:2], 'little') * 10000 + horizontal = timing[2] + ((timing[4] >> 4) << 8) + hblank = timing[3] + ((timing[4] & 15) << 8) + vertical = timing[5] + ((timing[7] >> 4) << 8) + vblank = timing[6] + ((timing[7] & 15) << 8) + actual = clock / (horizontal + hblank) / (vertical + vblank) + assert (horizontal, vertical) == (width, height) + assert abs(actual - expected) < 0.01, f'configured={configured}, frontend={frontend}: wanted {expected} Hz, got {actual}' + ranges = next(data[offset:offset + 18] for offset in (54, 72, 90, 108) if data[offset:offset + 5] == b'\0\0\0\xfd\0') + assert ranges[5] <= actual <= ranges[6], f'vertical range {ranges[5:7]} excludes {actual}' + horizontal_khz = clock / (horizontal + hblank) / 1000 + assert ranges[7] <= horizontal_khz <= ranges[8], f'horizontal range {ranges[7:9]} excludes {horizontal_khz}' + if expected <= 75: + assert ranges[5:9] == bytes((50, 125, 30, 160)), 'legacy monitor range changed' + print(f'PASS configured={configured}, frontend={frontend}: {width}x{height}@{actual:.6f}, ranges={list(ranges[5:9])}') diff --git a/dev/guest-probe.S b/dev/guest-probe.S new file mode 100644 index 0000000..aa82bb8 --- /dev/null +++ b/dev/guest-probe.S @@ -0,0 +1,25 @@ +.code16 +.global _start +_start: + cli + xor %ax,%ax + mov %ax,%ds + mov %ax,%ss + mov $0x7c00,%sp + movl $0x48594452,0x508 + movl $0,0x500 + movw $0,0x504 +loop: + incl 0x500 + in $0x64,%al + test $1,%al + jz loop + in $0x60,%al + mov 0x504,%bx + mov %al,0x600(%bx) + inc %bx + and $255,%bx + mov %bx,0x504 + jmp loop +.org 510 +.word 0xaa55 diff --git a/dev/input-probe.c b/dev/input-probe.c new file mode 100644 index 0000000..5c28d90 --- /dev/null +++ b/dev/input-probe.c @@ -0,0 +1,63 @@ +/* Private-display integration helper; stdin uses Linux evdev key codes. + * Compile via dev/build-input-probe.sh inside the temporary tools shell. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include "virtual-keyboard-client-protocol.h" +static struct wl_seat *seat; +static struct zwp_virtual_keyboard_manager_v1 *manager; +static void global(void *data, struct wl_registry *registry, uint32_t id, + const char *interface, uint32_t version) { + (void)data; (void)version; + if (!strcmp(interface,"wl_seat")) + seat = wl_registry_bind(registry,id,&wl_seat_interface,1); + else if (!strcmp(interface,"zwp_virtual_keyboard_manager_v1")) + manager = wl_registry_bind(registry,id,&zwp_virtual_keyboard_manager_v1_interface,1); +} +static void removed(void *data,struct wl_registry *registry,uint32_t id) { + (void)data; (void)registry; (void)id; +} +int main(void) { + /* The caller supplies the separately created scratch Wayland runtime. */ + assert(getenv("WAYLAND_DISPLAY") && getenv("XDG_RUNTIME_DIR")); + struct wl_display *display = wl_display_connect(NULL); assert(display); + struct wl_registry *registry = wl_display_get_registry(display); + struct wl_registry_listener listener={global,removed}; + wl_registry_add_listener(registry,&listener,NULL); wl_display_roundtrip(display); + assert(seat && manager); + struct zwp_virtual_keyboard_v1 *keyboard = zwp_virtual_keyboard_manager_v1_create_virtual_keyboard(manager,seat); + struct xkb_context *context=xkb_context_new(XKB_CONTEXT_NO_FLAGS); + struct xkb_rule_names names={.rules="evdev",.model="pc105",.layout="us"}; + struct xkb_keymap *map=xkb_keymap_new_from_names(context,&names,XKB_KEYMAP_COMPILE_NO_FLAGS); assert(map); + struct xkb_state *state=xkb_state_new(map); assert(state); + char *text=xkb_keymap_get_as_string(map,XKB_KEYMAP_FORMAT_TEXT_V1); + size_t size=strlen(text)+1; + int fd=memfd_create("hydra-private-keymap",0); assert(fd>=0); + assert(write(fd,text,size)==(ssize_t)size); + zwp_virtual_keyboard_v1_keymap(keyboard,WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1,fd,size); + wl_display_roundtrip(display); close(fd); free(text); + char command[16]; unsigned key; unsigned tick=0; + while (scanf("%15s %u",command,&key)==2) { + if (!strcmp(command,"wait")) { usleep(key*1000); continue; } + unsigned down=!strcmp(command,"down"); + assert(down || !strcmp(command,"up")); + xkb_state_update_key(state,key+8,down?XKB_KEY_DOWN:XKB_KEY_UP); + zwp_virtual_keyboard_v1_key(keyboard,++tick,key,down); + zwp_virtual_keyboard_v1_modifiers(keyboard, + xkb_state_serialize_mods(state,XKB_STATE_MODS_DEPRESSED), + xkb_state_serialize_mods(state,XKB_STATE_MODS_LATCHED), + xkb_state_serialize_mods(state,XKB_STATE_MODS_LOCKED), + xkb_state_serialize_layout(state,XKB_STATE_LAYOUT_EFFECTIVE)); + assert(wl_display_roundtrip(display)>=0); + } + zwp_virtual_keyboard_v1_destroy(keyboard); wl_display_roundtrip(display); + xkb_state_unref(state); xkb_keymap_unref(map); xkb_context_unref(context); + wl_display_disconnect(display); + return 0; +} diff --git a/dev/smoke.py b/dev/smoke.py index b3b3087..99c6134 100644 --- a/dev/smoke.py +++ b/dev/smoke.py @@ -1,14 +1,16 @@ #!/usr/bin/env python3 -"""Disposable headless GUI checks. Never connects to an inherited display.""" +"""Two disposable KVM guests on a private headless display; never host input.""" import argparse import json import os from pathlib import Path +import re import shutil import subprocess import sys import tempfile import time +from xml.etree import ElementTree as ET sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from hydra.profiles import Profile @@ -25,24 +27,49 @@ def wait_for(predicate, description, timeout=8): raise AssertionError(description) +def counter(manager, name): + with manager._connect(manager.paths(name)) as qmp: + reply = qmp.command('human-monitor-command', {'command-line': 'xp /3wx 0x500'}) + values = [int(word, 16) for word in re.findall(r'0x([0-9a-fA-F]+)', reply)] + assert len(values) == 3 and values[2] == 0x48594452, 'scratch guest did not boot' + return values[0] + + def main(): cli = argparse.ArgumentParser(description=__doc__) cli.add_argument('--viewer', default=os.environ.get('HYDRA_VIEWER', 'remote-viewer')) cli.add_argument('--labwc', default=os.environ.get('HYDRA_LABWC', 'labwc')) - cli.add_argument('--keyboard', default='wtype') + cli.add_argument('--keyboard', help='compiled dev/input-probe.c; built automatically when omitted') args = cli.parse_args() - for name in ('viewer', 'labwc', 'keyboard'): + for name in ('viewer', 'labwc'): value = shutil.which(getattr(args, name)) if not value: cli.error(f'missing {name}: {getattr(args, name)}') setattr(args, name, value) - root = Path(tempfile.mkdtemp(prefix='hydra-smoke-')) + source = Path(__file__).resolve().parent + if not args.keyboard: + args.keyboard = subprocess.check_output([str(source / 'build-input-probe.sh')], text=True).strip() + if not os.access('/dev/kvm', os.R_OK | os.W_OK): + cli.error('KVM access required for this integration check') + root = Path(tempfile.mkdtemp(prefix='hydra-smoke-', dir='/tmp')) print(f'Evidence: {root}', flush=True) xdg = root / 'xdg' xdg.mkdir(mode=0o700) config = root / 'labwc' config.mkdir() - (config / 'rc.xml').write_text(build_labwc_config(['probe'], ['/bin/true'])) + recovery = root / 'recovery' + unreserved = root / 'unreserved' + names = ['alpha', 'beta'] + rc = ET.fromstring(build_labwc_config(names, [sys.executable, '-c', + 'from pathlib import Path; Path(' + repr(str(recovery)) + ').touch()'], initial='alpha')) + # Test-only controls prove native labwc inhibition is active; production + # config has no ToggleKeybinds or ordinary host shortcut. + keyboard = rc.find('keyboard') + binding = ET.SubElement(keyboard, 'keybind', key='C-A-S-i') + ET.SubElement(binding, 'action', name='ToggleKeybinds') + binding = ET.SubElement(keyboard, 'keybind', key='F12') + ET.SubElement(binding, 'action', name='Execute', command=shutil.which('touch') + ' ' + str(unreserved)) + ET.ElementTree(rc).write(config / 'rc.xml') (config / 'autostart').write_text('#!/bin/sh\n') (config / 'environment').touch() env = dict(os.environ, XDG_RUNTIME_DIR=str(xdg), WLR_BACKENDS='headless', @@ -51,55 +78,140 @@ def main(): for key in ('DISPLAY', 'WAYLAND_DISPLAY'): env.pop(key, None) manager = Runtime(root / 'config', root / 'state', root / 'run') - manager.add(Profile('probe', memory=128, cpus=1, disk_size='8M', graphics='software')) - processes = [] + boot_object, boot = root / 'boot.o', root / 'boot.bin' + subprocess.run(['as', '--32', str(source / 'guest-probe.S'), '-o', str(boot_object)], check=True) + subprocess.run(['objcopy', '-j', '.text', '-O', 'binary', str(boot_object), str(boot)], check=True) + raw = root / 'boot.raw' + raw.write_bytes(boot.read_bytes() + bytes(8 * 1024 * 1024 - 512)) + media = root / 'installation.iso' + media.touch() + for name in names: + manager.add(Profile(name, media if name == 'alpha' else None, memory=128, cpus=1, disk_size='8M', graphics='software', + width=1920, height=1080, refresh_millihz=144000)) + paths = manager.paths(name) + manager.prepare(paths) + subprocess.run([os.environ.get('HYDRA_QEMU_IMG', 'qemu-img'), 'convert', '-f', 'raw', + '-O', 'qcow2', str(raw), str(paths.disk)], check=True) + processes, streams = [], [] + result = {'evidence': str(root), 'physical_checks_pending': True} try: - with (root / 'labwc.log').open('w') as cl, (root / 'viewer.log').open('w') as vl: - compositor = subprocess.Popen([args.labwc, '-d', '-C', str(config)], env=env, stdout=cl, stderr=cl) - processes.append(compositor) - wait_for(lambda: list(xdg.glob('wayland-*')), 'private display did not start') - env['WAYLAND_DISPLAY'] = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock')) - keyboard = subprocess.Popen([args.keyboard, '-k', 'Escape', '-s', '30000'], env=env) - processes.append(keyboard) - time.sleep(.2) - manager.start('probe') + cl = (root / 'labwc.log').open('w'); streams.append(cl) + compositor = subprocess.Popen([args.labwc, '-d', '-C', str(config)], env=env, stdout=cl, stderr=cl) + processes.append(compositor) + wait_for(lambda: any(not p.name.endswith('.lock') for p in xdg.glob('wayland-*')), 'private display did not start') + env['WAYLAND_DISPLAY'] = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock')) + injector = subprocess.Popen([args.keyboard], env=env, stdin=subprocess.PIPE, text=True) + processes.append(injector) + + def keys(commands): + injector.stdin.write(commands + '\nwait 100\n'); injector.stdin.flush() + time.sleep(.15) + assert injector.poll() is None, 'private input helper exited' + + def chord(code): + keys(f'down 29\ndown 56\ndown 42\ndown {code}\nup {code}\nup 42\nup 56\nup 29') + + def attach(name, number): + vl = (root / f'{name}-{number}.log').open('w'); streams.append(vl) previous = dict(os.environ) try: os.environ.update(env) - viewer_env = _viewer_environment(root / 'viewer-state') + viewer_env = _viewer_environment(root / f'{name}-{number}-settings') finally: - os.environ.clear() - os.environ.update(previous) + os.environ.clear(); os.environ.update(previous) viewer_env['WAYLAND_DEBUG'] = '1' - viewer = subprocess.Popen(build_viewer_argv(manager.paths('probe'), args.viewer), - env=viewer_env, stdout=vl, stderr=vl) - processes.append(viewer) - log = root / 'viewer.log' - wait_for(lambda: 'set_title("HydraOS: probe")' in log.read_text(), 'viewer did not connect') - time.sleep(.5) - title = 'set_title("Select USB devices for redirection")' - assert title not in log.read_text(), 'USB selector opened automatically' - subprocess.run([args.keyboard, '-M', 'ctrl', '-M', 'alt', '-M', 'shift', '-k', 'u', - '-m', 'shift', '-m', 'alt', '-m', 'ctrl'], env=env, check=True) - wait_for(lambda: title in log.read_text(), 'manual USB selector did not open', timeout=3) - viewer.terminate() - viewer.wait(timeout=5) - assert manager.status('probe')['status'] == 'running' - compositor.terminate() - compositor.wait(timeout=5) - assert manager.status('probe')['status'] == 'running' - result = {'manual_usb_dialog': True, 'automatic_usb_dialog': False, - 'qemu_survives_viewer_and_compositor': True, - 'viewer_version': subprocess.check_output([args.viewer, '--version'], text=True).strip()} - (root / 'result.json').write_text(json.dumps(result, indent=2) + '\n') - print(json.dumps(result, indent=2)) + proc = subprocess.Popen(build_viewer_argv(manager.paths(name), args.viewer), + env=viewer_env, stdout=vl, stderr=vl) + processes.append(proc) + log = root / f'{name}-{number}.log' + wait_for(lambda: f'set_title("HydraOS: {name}")' in log.read_text(), f'{name} viewer did not connect') + return proc, log + + for name in names: + manager.start(name) + media.unlink() # Open QEMU media disappears; status/control/view still work. + time.sleep(1) + before = {name: counter(manager, name) for name in names} + with manager._connect(manager.paths('alpha')) as qmp: + children = qmp.command('qom-list', {'path': '/machine/peripheral-anon'}) + video = next(child['name'] for child in children if child['type'] == 'child') + mode = {prop: qmp.command('qom-get', {'path': '/machine/peripheral-anon/' + video, 'property': prop}) + for prop in ('xres', 'yres', 'refresh_rate')} + assert mode == {'xres': 1920, 'yres': 1080, 'refresh_rate': 144000}, mode + usb_before = qmp.command('human-monitor-command', {'command-line': 'info usb'}) + identities = {name: manager.paths(name).pid.read_text().strip() for name in names} + alpha, alog = attach('alpha', 1) + beta, blog = attach('beta', 1) + wait_for(lambda: '.enter(' in alog.read_text(), 'alpha did not receive focus') + # F12 must work before inhibition; then become suppressed in alpha. + keys('down 88\nup 88') + wait_for(unreserved.exists, 'ordinary test binding failed before inhibition') + unreserved.unlink() + chord(23) # I: test-only ToggleKeybinds. + keys('down 88\nup 88') + assert not unreserved.exists(), 'keybinding inhibition did not suppress ordinary F12' + a_leave = alog.read_text().count('.leave(') + chord(106) # Right: production reserved host switch. + wait_for(lambda: alog.read_text().count('.leave(') > a_leave and '.enter(' in blog.read_text(), + 'reserved switch failed with inhibition') + chord(105) # Left: return to inhibited alpha. + chord(1) # Escape: production recovery binding still works. + wait_for(recovery.exists, 'reserved recovery failed with inhibition') + dialog = 'set_title("Select USB devices for redirection")' + assert dialog not in alog.read_text(), 'USB selector opened automatically' + # Hold modifiers through opening, then release inside the modal dialog. + keys('down 29\ndown 56\ndown 42\ndown 22') + wait_for(lambda: dialog in alog.read_text(), 'manual USB selector did not open', timeout=3) + keys('up 22\nup 42\nup 56\nup 29') + keys('down 1\nup 1') # Close selector. + chord(106) + chord(105) + assert alpha.poll() is None and beta.poll() is None + assert identities == {name: manager.paths(name).pid.read_text().strip() for name in names}, 'switch restarted a guest' + assert alog.read_text().count('set_title("HydraOS: alpha")') == 1, 'switch reconnected alpha' + assert blog.read_text().count('set_title("HydraOS: beta")') == 1, 'switch reconnected beta' + with manager._connect(manager.paths('alpha')) as qmp: + usb_after = qmp.command('human-monitor-command', {'command-line': 'info usb'}) + assert usb_after == usb_before, 'opening selector attached an unselected USB device' + alpha.terminate(); alpha.wait(timeout=5) + assert manager.status('alpha')['status'] == 'running' + alpha, attached = attach('alpha', 2) + after = {name: counter(manager, name) for name in names} + assert all(after[name] != before[name] for name in names), 'background guest stopped working' + # Quit only beta; alpha's counter/lifecycle remains independent. + manager.stop('beta', force=True) + assert manager.status('alpha')['status'] == 'running' + assert counter(manager, 'alpha') != after['alpha'] + compositor.terminate(); compositor.wait(timeout=5) + assert manager.status('alpha')['status'] == 'running' + manager.stop('alpha', force=True) + manager.media('alpha', None) + assert manager.profile('alpha').iso is None + manager.start('alpha') + time.sleep(1) + disk_boot_counter = counter(manager, 'alpha') + assert disk_boot_counter > 0 + result.update(manual_usb_dialog=True, standard_us_keymap=True, + native_labwc_inhibition=True, reserved_switch_and_recovery=True, + qemu_survives_viewer_and_compositor=True, independent_lifecycle=True, + guest_counters_before=before, guest_counters_after=after, + guest_pids=identities, explicit_reattach=True, requested_qemu_mode=mode, + usb_devices_unchanged_without_selection=True, + missing_iso_does_not_block_views_or_control=True, + restart_without_original_media=True, disk_boot_counter=disk_boot_counter, + viewer_version=subprocess.check_output([args.viewer, '--version'], text=True).strip(), + guest_input_and_physical_modifiers='not proven by this boot counter workload') + (root / 'result.json').write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps(result, indent=2)) finally: for proc in reversed(processes): if proc.poll() is None: - proc.terminate() - proc.wait(timeout=5) - if manager.status('probe')['status'] != 'stopped': - manager.stop('probe', force=True) + proc.terminate(); proc.wait(timeout=5) + for stream in streams: + stream.close() + for name in names: + if manager.status(name)['status'] != 'stopped': + manager.stop(name, force=True) if __name__ == '__main__': diff --git a/dev/tools.nix b/dev/tools.nix index d1a5bc9..5424502 100644 --- a/dev/tools.nix +++ b/dev/tools.nix @@ -8,7 +8,7 @@ let url = "https://github.com/NixOS/nixpkgs/archive/7fd4004d0511991fff3a0524856a9be09e28c5f0.tar.gz"; sha256 = "sha256-i5qk1kLiit98rRbh1AXmWBNT9QXC/y4l5Wk/u5EdpDQ="; }) {}; - qemu = pkgs.qemu.overrideAttrs (old: { + qemu = (pkgs.qemu.override { hostCpuOnly = true; }).overrideAttrs (old: { patches = (old.patches or []) ++ [ ../patches/qemu-virtio-refresh-rate.patch ]; }); viewer = pkgs.virt-viewer.overrideAttrs (old: { @@ -20,6 +20,8 @@ let doCheck = true; }); in pkgs.mkShell { + nativeBuildInputs = [ pkgs.stdenv.cc pkgs.pkg-config pkgs.wayland-scanner ]; + buildInputs = [ pkgs.wayland.dev pkgs.libxkbcommon.dev ]; packages = [ pkgs.python3 qemu viewer graphics.labwc pkgs.foot pkgs.brightnessctl pkgs.wayland-utils pkgs.wlr-randr ]; passthru = { inherit viewer qemu; labwc = graphics.labwc; }; diff --git a/dev/virtual-keyboard-unstable-v1.xml b/dev/virtual-keyboard-unstable-v1.xml new file mode 100644 index 0000000..5095c91 --- /dev/null +++ b/dev/virtual-keyboard-unstable-v1.xml @@ -0,0 +1,113 @@ + + + + Copyright © 2008-2011 Kristian Høgsberg + Copyright © 2010-2013 Intel Corporation + Copyright © 2012-2013 Collabora, Ltd. + Copyright © 2018 Purism SPC + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice (including the next + paragraph) shall be included in all copies or substantial portions of the + Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + + + + + The virtual keyboard provides an application with requests which emulate + the behaviour of a physical keyboard. + + This interface can be used by clients on its own to provide raw input + events, or it can accompany the input method protocol. + + + + + Provide a file descriptor to the compositor which can be + memory-mapped to provide a keyboard mapping description. + + Format carries a value from the keymap_format enumeration. + + + + + + + + + + + + + A key was pressed or released. + The time argument is a timestamp with millisecond granularity, with an + undefined base. All requests regarding a single object must share the + same clock. + + Keymap must be set before issuing this request. + + State carries a value from the key_state enumeration. + + + + + + + + + Notifies the compositor that the modifier and/or group state has + changed, and it should update state. + + The client should use wl_keyboard.modifiers event to synchronize its + internal state with seat state. + + Keymap must be set before issuing this request. + + + + + + + + + + + + + + + A virtual keyboard manager allows an application to provide keyboard + input events as if they came from a physical keyboard. + + + + + + + + + Creates a new virtual keyboard associated to a seat. + + If the compositor enables a keyboard to perform arbitrary actions, it + should present an error when an untrusted client requests a new + keyboard. + + + + + + diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..d3234ad --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,112 @@ +# Graphics and recovery verification + +Run from the checkout. Tools are temporary Nix development dependencies; no +persistent host installation is required. The two-guest smoke test needs access +to `/dev/kvm` and creates its own headless Wayland display, VM disks and sockets +under a fresh `/tmp/hydra-smoke-*` directory. It never uses the inherited display, +active TTY, existing guests or existing disks. Evidence remains in that directory; +the script stops only its own verified QEMU instances on exit. + +```sh +python3 -m unittest discover -s tests -v +nix-build dev/tools.nix -A viewer --no-out-link +nix-build dev/tools.nix -A qemu --no-out-link +nix-shell dev/tools.nix --run 'python3 dev/smoke.py' +``` + +The smoke test builds a private input helper with an ordinary evdev/pc105 US +keyboard map. It boots two tiny counter workloads from separate scratch qcow2 +disks, connects two fullscreen viewers, and checks reserved switching/recovery, +manual USB selection, explicit reconnect and independent QMP lifecycles. +Guest memory counters must advance and QEMU PIDs must remain unchanged through +switching. Viewer protocol logs must show no reconnection during switching. +A test-only `ToggleKeybinds` action and F12 marker establish native labwc +keybinding inhibition before checking the production reserved bindings. Those +extra controls are absent from the production session configuration. See the +[labwc configuration manual](https://labwc.github.io/labwc-config.5.html) for +`overrideInhibition` and the [actions manual](https://labwc.github.io/labwc-actions.5.html) +for native `ToggleKeybinds` behavior. + +USB acceptance checks the actual selector window title, holds the modifiers +through opening, releases them inside the modal dialog, then switches again. +QEMU's USB device list must stay unchanged without a selection. Both viewer +USB filters deny automatic hotplug and connection redirection. This check does +not attach a physical USB device or prove guest driver behavior. + +The viewer adaptation handles only the configured manual USB accelerator before +forwarding an event to the guest. Upstream keyboard grabs remove the GTK +application accelerator group; without this adaptation, the configured USB +shortcut reaches the viewer but does not open its selector. + +## Installed guest workflow + +`--iso` is optional. Attach or detach media only while the VM is stopped: + +```sh +bin/hydra add guest --iso /absolute/path/installer.iso +bin/hydra start guest +bin/hydra view guest +# Shut down from inside the guest, then check it is stopped. +bin/hydra status guest +bin/hydra media guest --detach +bin/hydra start guest +``` + +For an existing stopped guest, `bin/hydra media guest --iso PATH` attaches +installation/rescue media. A missing configured ISO prevents a new start with +an actionable error; it does not prevent listing profiles, status, stopping a +running guest, reconnecting a viewer or detaching the missing media. Media +updates share the VM lifecycle lock and preserve its qcow2 disk. Abrupt stopping +still requires `bin/hydra stop guest --force` and verified QMP identity. + +## Display preferences and EDID + +New developer profiles use user networking, 1280×800 and `refresh_millihz=0`. +Zero omits the patched QEMU refresh property and remains compatible with stock +QEMU. Hydra's installed defaults can use this flat `/etc/hydra/defaults.toml`: + +```toml +network = "bridge" +bridge = "hydrabr0" +width = 1920 +height = 1080 +refresh_millihz = 144000 +``` + +Only `add` reads distribution defaults. Explicit `--network`, `--bridge`, +`--width`, `--height` and `--refresh-millihz` options win; existing profiles do +not change. `--defaults PATH` selects another preferences file for development. +Missing defaults use developer preferences; unknown or invalid fields fail +before creating a profile. Dimensions are integers from 1 to 16384, refresh is +an integer from 0 to 1000000 mHz. Nonzero refresh requires the tracked patched +QEMU provided by `dev/tools.nix` and distribution packaging. + +The patch keeps the requested refresh through frontend resizing and extends +EDID monitor ranges to include the generated preferred mode. To verify the +actual patched QEMU generator with the pinned source archive: + +```sh +qemu_source=$(nix-build dev/tools.nix -A qemu.src --no-out-link) +nix-shell dev/tools.nix --run "python3 dev/check-edid.py '$qemu_source'" +``` + +The probe applies the tracked patch to four relevant source files in a temporary +directory, compiles the actual EDID and virtio GPU generator functions with a +small dependency harness, and decodes the result. It checks 60/75 Hz fallback, +144 Hz configuration, a conflicting 60 Hz frontend request, resized dimensions, +checksums and monitor ranges. At 1920×1080/144000 mHz the preferred timing is +143.997231 Hz after EDID clock rounding. This is an advertised guest mode, +not measured frame delivery or proof of a 144 Hz physical output. + +## Still requiring a dedicated physical session + +- Physical reserved chords, brightness/backlight permissions and touchpad input. +- Physical held keys/modifiers across guest switching, grabs and modal dialogs; + guest key-up delivery, pointer grab release and absence of stuck modifiers. +- Physical USB selection/redirection and guest drivers; no automatic redirection. +- Toolbar behavior at physical screen edges and actual recovery shell usability. +- ASUS output selection at 1920×1080/144 Hz, guest-reported display mode, delivered + frame rate, hidden guest graphics continuity, tearing and switching latency. + +Headless checks establish CPU workload continuity, protocol focus transitions +and the exercised compositor actions. They cannot prove physical input or scanout. diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 6bee6b8..3529cd6 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -33,6 +33,11 @@ class ProfilesTests(unittest.TestCase): with self.subTest(field=field, value=value), self.assertRaises(ValueError): Profile('guest', self.iso, **{field: value}) + def test_invalid_media_path_types(self): + for iso in (True, 42, ''): + with self.subTest(iso=iso), self.assertRaises(ValueError): + Profile('guest', iso) + def test_duplicate_profile_preserves_existing(self): dest = self.root / 'guest.toml' save_profile(dest, Profile('guest', self.iso)) diff --git a/tests/test_session.py b/tests/test_session.py index 58a7cfb..80bb4e2 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -82,7 +82,8 @@ class SessionTests(unittest.TestCase): self.assertEqual(main(base + ['view', 'alpha']), 1) self.assertIn('viewer', error.getvalue().lower()) - def test_windowed_viewer_exit_leaves_vm_control_untouched(self): + def test_windowed_viewer_exit_with_missing_iso_leaves_vm_control_untouched(self): + self.iso.unlink() viewer = self.root / 'fake-viewer' viewer.write_text('#!/bin/sh\ncase "$1" in --help) echo --hide-toolbar; exit 0;; esac\nexit 7\n') viewer.chmod(0o700)