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)
|
- [Hardware target](docs/hardware/asus-fx517zc.md)
|
||||||
|
|
||||||
Build outputs, VM disks, runtime state and credentials stay outside Git.
|
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
|
#!/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 argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
from hydra.profiles import Profile
|
from hydra.profiles import Profile
|
||||||
@@ -25,24 +27,49 @@ def wait_for(predicate, description, timeout=8):
|
|||||||
raise AssertionError(description)
|
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():
|
def main():
|
||||||
cli = argparse.ArgumentParser(description=__doc__)
|
cli = argparse.ArgumentParser(description=__doc__)
|
||||||
cli.add_argument('--viewer', default=os.environ.get('HYDRA_VIEWER', 'remote-viewer'))
|
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('--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()
|
args = cli.parse_args()
|
||||||
for name in ('viewer', 'labwc', 'keyboard'):
|
for name in ('viewer', 'labwc'):
|
||||||
value = shutil.which(getattr(args, name))
|
value = shutil.which(getattr(args, name))
|
||||||
if not value:
|
if not value:
|
||||||
cli.error(f'missing {name}: {getattr(args, name)}')
|
cli.error(f'missing {name}: {getattr(args, name)}')
|
||||||
setattr(args, name, value)
|
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)
|
print(f'Evidence: {root}', flush=True)
|
||||||
xdg = root / 'xdg'
|
xdg = root / 'xdg'
|
||||||
xdg.mkdir(mode=0o700)
|
xdg.mkdir(mode=0o700)
|
||||||
config = root / 'labwc'
|
config = root / 'labwc'
|
||||||
config.mkdir()
|
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 / 'autostart').write_text('#!/bin/sh\n')
|
||||||
(config / 'environment').touch()
|
(config / 'environment').touch()
|
||||||
env = dict(os.environ, XDG_RUNTIME_DIR=str(xdg), WLR_BACKENDS='headless',
|
env = dict(os.environ, XDG_RUNTIME_DIR=str(xdg), WLR_BACKENDS='headless',
|
||||||
@@ -51,55 +78,140 @@ def main():
|
|||||||
for key in ('DISPLAY', 'WAYLAND_DISPLAY'):
|
for key in ('DISPLAY', 'WAYLAND_DISPLAY'):
|
||||||
env.pop(key, None)
|
env.pop(key, None)
|
||||||
manager = Runtime(root / 'config', root / 'state', root / 'run')
|
manager = Runtime(root / 'config', root / 'state', root / 'run')
|
||||||
manager.add(Profile('probe', memory=128, cpus=1, disk_size='8M', graphics='software'))
|
boot_object, boot = root / 'boot.o', root / 'boot.bin'
|
||||||
processes = []
|
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:
|
try:
|
||||||
with (root / 'labwc.log').open('w') as cl, (root / 'viewer.log').open('w') as vl:
|
cl = (root / 'labwc.log').open('w'); streams.append(cl)
|
||||||
compositor = subprocess.Popen([args.labwc, '-d', '-C', str(config)], env=env, stdout=cl, stderr=cl)
|
compositor = subprocess.Popen([args.labwc, '-d', '-C', str(config)], env=env, stdout=cl, stderr=cl)
|
||||||
processes.append(compositor)
|
processes.append(compositor)
|
||||||
wait_for(lambda: list(xdg.glob('wayland-*')), 'private display did not start')
|
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'))
|
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)
|
injector = subprocess.Popen([args.keyboard], env=env, stdin=subprocess.PIPE, text=True)
|
||||||
processes.append(keyboard)
|
processes.append(injector)
|
||||||
time.sleep(.2)
|
|
||||||
manager.start('probe')
|
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)
|
previous = dict(os.environ)
|
||||||
try:
|
try:
|
||||||
os.environ.update(env)
|
os.environ.update(env)
|
||||||
viewer_env = _viewer_environment(root / 'viewer-state')
|
viewer_env = _viewer_environment(root / f'{name}-{number}-settings')
|
||||||
finally:
|
finally:
|
||||||
os.environ.clear()
|
os.environ.clear(); os.environ.update(previous)
|
||||||
os.environ.update(previous)
|
|
||||||
viewer_env['WAYLAND_DEBUG'] = '1'
|
viewer_env['WAYLAND_DEBUG'] = '1'
|
||||||
viewer = subprocess.Popen(build_viewer_argv(manager.paths('probe'), args.viewer),
|
proc = subprocess.Popen(build_viewer_argv(manager.paths(name), args.viewer),
|
||||||
env=viewer_env, stdout=vl, stderr=vl)
|
env=viewer_env, stdout=vl, stderr=vl)
|
||||||
processes.append(viewer)
|
processes.append(proc)
|
||||||
log = root / 'viewer.log'
|
log = root / f'{name}-{number}.log'
|
||||||
wait_for(lambda: 'set_title("HydraOS: probe")' in log.read_text(), 'viewer did not connect')
|
wait_for(lambda: f'set_title("HydraOS: {name}")' in log.read_text(), f'{name} viewer did not connect')
|
||||||
time.sleep(.5)
|
return proc, log
|
||||||
title = 'set_title("Select USB devices for redirection")'
|
|
||||||
assert title not in log.read_text(), 'USB selector opened automatically'
|
for name in names:
|
||||||
subprocess.run([args.keyboard, '-M', 'ctrl', '-M', 'alt', '-M', 'shift', '-k', 'u',
|
manager.start(name)
|
||||||
'-m', 'shift', '-m', 'alt', '-m', 'ctrl'], env=env, check=True)
|
media.unlink() # Open QEMU media disappears; status/control/view still work.
|
||||||
wait_for(lambda: title in log.read_text(), 'manual USB selector did not open', timeout=3)
|
time.sleep(1)
|
||||||
viewer.terminate()
|
before = {name: counter(manager, name) for name in names}
|
||||||
viewer.wait(timeout=5)
|
with manager._connect(manager.paths('alpha')) as qmp:
|
||||||
assert manager.status('probe')['status'] == 'running'
|
children = qmp.command('qom-list', {'path': '/machine/peripheral-anon'})
|
||||||
compositor.terminate()
|
video = next(child['name'] for child in children if child['type'] == 'child<virtio-vga>')
|
||||||
compositor.wait(timeout=5)
|
mode = {prop: qmp.command('qom-get', {'path': '/machine/peripheral-anon/' + video, 'property': prop})
|
||||||
assert manager.status('probe')['status'] == 'running'
|
for prop in ('xres', 'yres', 'refresh_rate')}
|
||||||
result = {'manual_usb_dialog': True, 'automatic_usb_dialog': False,
|
assert mode == {'xres': 1920, 'yres': 1080, 'refresh_rate': 144000}, mode
|
||||||
'qemu_survives_viewer_and_compositor': True,
|
usb_before = qmp.command('human-monitor-command', {'command-line': 'info usb'})
|
||||||
'viewer_version': subprocess.check_output([args.viewer, '--version'], text=True).strip()}
|
identities = {name: manager.paths(name).pid.read_text().strip() for name in names}
|
||||||
(root / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
|
alpha, alog = attach('alpha', 1)
|
||||||
print(json.dumps(result, indent=2))
|
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:
|
finally:
|
||||||
for proc in reversed(processes):
|
for proc in reversed(processes):
|
||||||
if proc.poll() is None:
|
if proc.poll() is None:
|
||||||
proc.terminate()
|
proc.terminate(); proc.wait(timeout=5)
|
||||||
proc.wait(timeout=5)
|
for stream in streams:
|
||||||
if manager.status('probe')['status'] != 'stopped':
|
stream.close()
|
||||||
manager.stop('probe', force=True)
|
for name in names:
|
||||||
|
if manager.status(name)['status'] != 'stopped':
|
||||||
|
manager.stop(name, force=True)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
+3
-1
@@ -8,7 +8,7 @@ let
|
|||||||
url = "https://github.com/NixOS/nixpkgs/archive/7fd4004d0511991fff3a0524856a9be09e28c5f0.tar.gz";
|
url = "https://github.com/NixOS/nixpkgs/archive/7fd4004d0511991fff3a0524856a9be09e28c5f0.tar.gz";
|
||||||
sha256 = "sha256-i5qk1kLiit98rRbh1AXmWBNT9QXC/y4l5Wk/u5EdpDQ=";
|
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 ];
|
patches = (old.patches or []) ++ [ ../patches/qemu-virtio-refresh-rate.patch ];
|
||||||
});
|
});
|
||||||
viewer = pkgs.virt-viewer.overrideAttrs (old: {
|
viewer = pkgs.virt-viewer.overrideAttrs (old: {
|
||||||
@@ -20,6 +20,8 @@ let
|
|||||||
doCheck = true;
|
doCheck = true;
|
||||||
});
|
});
|
||||||
in pkgs.mkShell {
|
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
|
packages = [ pkgs.python3 qemu viewer graphics.labwc pkgs.foot
|
||||||
pkgs.brightnessctl pkgs.wayland-utils pkgs.wlr-randr ];
|
pkgs.brightnessctl pkgs.wayland-utils pkgs.wlr-randr ];
|
||||||
passthru = { inherit viewer qemu; labwc = graphics.labwc; };
|
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):
|
with self.subTest(field=field, value=value), self.assertRaises(ValueError):
|
||||||
Profile('guest', self.iso, **{field: value})
|
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):
|
def test_duplicate_profile_preserves_existing(self):
|
||||||
dest = self.root / 'guest.toml'
|
dest = self.root / 'guest.toml'
|
||||||
save_profile(dest, Profile('guest', self.iso))
|
save_profile(dest, Profile('guest', self.iso))
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ class SessionTests(unittest.TestCase):
|
|||||||
self.assertEqual(main(base + ['view', 'alpha']), 1)
|
self.assertEqual(main(base + ['view', 'alpha']), 1)
|
||||||
self.assertIn('viewer', error.getvalue().lower())
|
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 = self.root / 'fake-viewer'
|
||||||
viewer.write_text('#!/bin/sh\ncase "$1" in --help) echo --hide-toolbar; exit 0;; esac\nexit 7\n')
|
viewer.write_text('#!/bin/sh\ncase "$1" in --help) echo --hide-toolbar; exit 0;; esac\nexit 7\n')
|
||||||
viewer.chmod(0o700)
|
viewer.chmod(0o700)
|
||||||
|
|||||||
Reference in New Issue
Block a user