test: verify independent guests and recovery on private displays
This commit is contained in:
@@ -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).
|
||||
|
||||
Executable
+8
@@ -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"
|
||||
@@ -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 <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#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])}')
|
||||
@@ -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
|
||||
@@ -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 <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
#include <wayland-client.h>
|
||||
#include <xkbcommon/xkbcommon.h>
|
||||
#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;
|
||||
}
|
||||
+157
-45
@@ -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<virtio-vga>')
|
||||
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__':
|
||||
|
||||
+3
-1
@@ -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; };
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="virtual_keyboard_unstable_v1">
|
||||
<copyright>
|
||||
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.
|
||||
</copyright>
|
||||
|
||||
<interface name="zwp_virtual_keyboard_v1" version="1">
|
||||
<description summary="virtual keyboard">
|
||||
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.
|
||||
</description>
|
||||
|
||||
<request name="keymap">
|
||||
<description summary="keyboard mapping">
|
||||
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.
|
||||
</description>
|
||||
<arg name="format" type="uint" summary="keymap format"/>
|
||||
<arg name="fd" type="fd" summary="keymap file descriptor"/>
|
||||
<arg name="size" type="uint" summary="keymap size, in bytes"/>
|
||||
</request>
|
||||
|
||||
<enum name="error">
|
||||
<entry name="no_keymap" value="0" summary="No keymap was set"/>
|
||||
</enum>
|
||||
|
||||
<request name="key">
|
||||
<description summary="key event">
|
||||
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.
|
||||
</description>
|
||||
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
|
||||
<arg name="key" type="uint" summary="key that produced the event"/>
|
||||
<arg name="state" type="uint" summary="physical state of the key"/>
|
||||
</request>
|
||||
|
||||
<request name="modifiers">
|
||||
<description summary="modifier and group state">
|
||||
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.
|
||||
</description>
|
||||
<arg name="mods_depressed" type="uint" summary="depressed modifiers"/>
|
||||
<arg name="mods_latched" type="uint" summary="latched modifiers"/>
|
||||
<arg name="mods_locked" type="uint" summary="locked modifiers"/>
|
||||
<arg name="group" type="uint" summary="keyboard layout"/>
|
||||
</request>
|
||||
|
||||
<request name="destroy" type="destructor" since="1">
|
||||
<description summary="destroy the virtual keyboard keyboard object"/>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="zwp_virtual_keyboard_manager_v1" version="1">
|
||||
<description summary="virtual keyboard manager">
|
||||
A virtual keyboard manager allows an application to provide keyboard
|
||||
input events as if they came from a physical keyboard.
|
||||
</description>
|
||||
|
||||
<enum name="error">
|
||||
<entry name="unauthorized" value="0" summary="client not authorized to use the interface"/>
|
||||
</enum>
|
||||
|
||||
<request name="create_virtual_keyboard">
|
||||
<description summary="Create a new virtual 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.
|
||||
</description>
|
||||
<arg name="seat" type="object" interface="wl_seat"/>
|
||||
<arg name="id" type="new_id" interface="zwp_virtual_keyboard_v1"/>
|
||||
</request>
|
||||
</interface>
|
||||
</protocol>
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user