From 2f48de0aa2d5e69c0514ff2cd9c3a6fbde76b868 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 05:12:04 +0300 Subject: [PATCH] fix: preserve guest network identities and refresh session profiles --- README.md | 7 ++ dev/check-network.py | 78 ++++++++++++++ dev/session-refresh-smoke.py | 196 +++++++++++++++++++++++++++++++++++ docs/verification.md | 44 ++++++++ hydra/profiles.py | 7 ++ hydra/runtime.py | 13 ++- hydra/session.py | 79 +++++++++----- tests/test_profiles.py | 9 ++ tests/test_runtime.py | 42 +++++++- tests/test_session.py | 44 ++++++++ 10 files changed, 493 insertions(+), 26 deletions(-) create mode 100644 dev/check-network.py create mode 100644 dev/session-refresh-smoke.py diff --git a/README.md b/README.md index 30ebd69..1b09329 100644 --- a/README.md +++ b/README.md @@ -46,3 +46,10 @@ in `/etc/hydra/defaults.toml` affect only new profiles; explicit CLI options win 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). + +An empty session opens local recovery. Enter `:shell`, create and start guests +with `hydra add` / `hydra start`, then leave the shell and enter `:refresh`. +Refresh rebuilds workspaces and reconnects viewers for the current profiles; +running VMs keep their processes and disks. Use it after adding or removing +profiles. New profiles save an individual MAC address; legacy profiles obtain +a stable address from their owner and disk path, including on the shared bridge. diff --git a/dev/check-network.py b/dev/check-network.py new file mode 100644 index 0000000..b186931 --- /dev/null +++ b/dev/check-network.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Check real NIC identities on a bridge isolated in user/net/mount namespaces.""" +import argparse +import json +from pathlib import Path +import subprocess +import sys +import tempfile + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from hydra.profiles import Profile +from hydra.runtime import Runtime + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--namespace-root', type=Path, help=argparse.SUPPRESS) + args = parser.parse_args() + if args.namespace_root is None: + root = Path(tempfile.mkdtemp(prefix='hydra-network-', dir='/tmp')) + print('Evidence: ' + str(root), flush=True) + (root / 'parent-namespaces.json').write_text(json.dumps({name: str(Path('/proc/self/ns', name).readlink()) + for name in ('user', 'net', 'mnt')})) + # All bridge and /etc changes below occur after this explicit namespace + # boundary. The inherited host bridge configuration is never modified. + subprocess.run(['unshare', '-Urnm', '--', str(Path(sys.executable).resolve()), + str(Path(__file__).resolve()), '--namespace-root', str(root)], check=True) + return + root = args.namespace_root + parent_namespaces = json.loads((root / 'parent-namespaces.json').read_text()) + namespaces = {name: str(Path('/proc/self/ns', name).readlink()) for name in ('user', 'net', 'mnt')} + assert all(namespaces[name] != parent_namespaces[name] for name in namespaces), 'private namespaces required' + private_etc = root / 'etc' + (private_etc / 'qemu').mkdir(parents=True) + (private_etc / 'qemu/bridge.conf').write_text('allow hydrabr0\n') + subprocess.run(['mount', '--bind', str(private_etc), '/etc'], check=True) + subprocess.run(['ip', 'link', 'add', 'hydrabr0', 'type', 'bridge'], check=True) + subprocess.run(['ip', 'link', 'set', 'hydrabr0', 'up'], check=True) + manager = Runtime(root / 'config', root / 'state', root / 'run') + result = {'evidence': str(root), 'namespace': 'private user/net/mount', + 'parent_namespaces': parent_namespaces, 'probe_namespaces': namespaces, 'names': {}} + try: + for name in ('alpha', 'beta'): + manager.add(Profile(name, memory=128, cpus=1, disk_size='8M', + graphics='software', network='bridge')) + manager.start(name) + + def actual_mac(name): + with manager._connect(manager.paths(name)) as qmp: + network = qmp.command('human-monitor-command', {'command-line': 'info network'}) + mac = manager.profile(name).mac + assert 'macaddr=' + mac in network, network + assert int(mac.split(':')[0], 16) & 3 == 2 + return mac, network + + for name in ('alpha', 'beta'): + mac, network = actual_mac(name) + result['names'][name] = {'mac': mac, 'network': network} + assert result['names']['alpha']['mac'] != result['names']['beta']['mac'] + links = json.loads(subprocess.check_output(['ip', '-j', 'link', 'show', 'master', 'hydrabr0'], text=True)) + assert len(links) == 2, links + result['bridge_ports'] = [link['ifname'] for link in links] + manager.stop('alpha', force=True) + manager.start('alpha') + assert actual_mac('alpha')[0] == result['names']['alpha']['mac'] + result['restart_preserved_mac'] = True + result['dhcp_and_guest_network_drivers_pending'] = True + result['passed'] = True + (root / 'result.json').write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps(result, indent=2)) + finally: + for profile in manager.profiles(): + if manager.status(profile.name)['status'] == 'running': + manager.stop(profile.name, force=True) + + +if __name__ == '__main__': + main() diff --git a/dev/session-refresh-smoke.py b/dev/session-refresh-smoke.py new file mode 100644 index 0000000..d87e514 --- /dev/null +++ b/dev/session-refresh-smoke.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Exercise the product's empty-session/recovery/refresh loop privately.""" +import argparse +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile +import time + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from hydra.runtime import Runtime +from smoke import (wait_for, counter, focus_events, fresh_focus_transition, + measure_hidden_interval, classify_compositor_errors) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--labwc', default=os.environ.get('HYDRA_LABWC', 'labwc')) + parser.add_argument('--viewer', default=os.environ.get('HYDRA_VIEWER', 'remote-viewer')) + parser.add_argument('--keyboard', required=True, help='compiled dev/input-probe.c') + args = parser.parse_args() + for name in ('labwc', 'viewer', 'keyboard'): + executable = shutil.which(getattr(args, name)) + if not executable: + parser.error('missing executable: ' + getattr(args, name)) + setattr(args, name, executable) + if not os.access('/dev/kvm', os.R_OK | os.W_OK): + parser.error('KVM access required') + source = Path(__file__).resolve().parents[1] + root = Path(tempfile.mkdtemp(prefix='hydra-session-refresh-', dir='/tmp')) + print('Evidence: ' + str(root), flush=True) + xdg = root / 'xdg'; xdg.mkdir(mode=0o700) + manager = Runtime(root / 'config', root / 'state', root / 'run') + cli = [sys.executable, str(source / 'bin/hydra'), '--config', str(manager.config), + '--state', str(manager.state), '--runtime', str(manager.runtime)] + subprocess.run(['as', '--32', str(source / 'dev/guest-probe.S'), '-o', str(root / 'boot.o')], check=True) + subprocess.run(['objcopy', '-j', '.text', '-O', 'binary', str(root / 'boot.o'), str(root / 'boot.bin')], check=True) + (root / 'boot.raw').write_bytes((root / 'boot.bin').read_bytes() + bytes(8 * 1024 * 1024 - 512)) + + def wrapper(name, code): + path = root / name + path.write_text('#!' + sys.executable + '\n' + code) + path.chmod(0o700) + return str(path) + + common = 'import json, os, pathlib, subprocess, sys, time\nroot = pathlib.Path(' + repr(str(root)) + ')\n' + labwc = wrapper('labwc-private', common + ''' +if '--version' in sys.argv: + os.execv(@LABWC@, [@LABWC@] + sys.argv[1:]) +config = pathlib.Path(sys.argv[sys.argv.index('-C') + 1]) +number = len(list(root.glob('snapshot-*.json'))) +data = json.loads((config.parent / 'session.json').read_text()) +data.update(pid=os.getpid(), config_directory=str(config)) +(root / ('snapshot-%d.xml' % number)).write_text((config / 'rc.xml').read_text()) +(root / ('snapshot-%d.json' % number)).write_text(json.dumps(data)) +# Test-only wrapper replaces physical backends before the real compositor runs. +os.environ.update(WLR_BACKENDS='headless', WLR_RENDERER='pixman', + WLR_XWAYLAND='/bin/false', LABWC_UPDATE_ACTIVATION_ENV='0', + XDG_DATA_DIRS='/nonexistent') +os.execv(@LABWC@, [@LABWC@, '-d'] + sys.argv[1:]) +'''.replace('@LABWC@', repr(args.labwc))) + viewer = wrapper('viewer-private', common + ''' +if '--help' in sys.argv: + os.execv(VIEWER, [VIEWER] + sys.argv[1:]) +name = sys.argv[sys.argv.index('--title') + 1].split(': ', 1)[1] +(root / (name + '-viewer.json')).write_text(json.dumps({ + 'argv': sys.argv[1:], 'config': os.environ['XDG_CONFIG_HOME'], + 'gdk_backend': os.environ.get('GDK_BACKEND'), 'wayland_display': os.environ['WAYLAND_DISPLAY']})) +log = os.open(root / (name + '.log'), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) +os.dup2(log, 1); os.dup2(log, 2) +os.environ['WAYLAND_DEBUG'] = '1' +os.execv(VIEWER, [VIEWER] + sys.argv[1:]) +'''.replace('VIEWER', repr(args.viewer))) + terminal = wrapper('terminal-private', common + ''' +manifest = pathlib.Path(sys.argv[-1]) +socket = pathlib.Path(os.environ['XDG_RUNTIME_DIR']) / os.environ['WAYLAND_DISPLAY'] +(root / 'recovery-ready').write_text(str(manifest)) +while not (root / 'recovery-input').exists(): + if not socket.exists(): + sys.exit(0) + time.sleep(.05) +if not socket.exists(): + sys.exit(0) +commands = (root / 'recovery-input').read_text() +# The actual product recovery helper executes :shell and :refresh. +with (root / 'recovery.log').open('a') as log: + sys.exit(subprocess.run(sys.argv[2:], input=commands, text=True, stdout=log, stderr=log).returncode) +''') + shell = wrapper('scratch-shell', common + 'sys.path.insert(0, ' + repr(str(source)) + ')\n' + ''' +from hydra.runtime import Runtime +from smoke import counter, wait_for +cli = CLI +manager = Runtime(root / 'config', root / 'state', root / 'run') +for name in ('alpha', 'beta'): + subprocess.run(cli + ['add', name, '--memory', '128', '--cpus', '1', '--disk-size', '8M', + '--graphics', 'software', '--network', 'user'], check=True) + paths = manager.paths(name); manager.prepare(paths) + subprocess.run([os.environ['HYDRA_QEMU_IMG'], 'convert', '-f', 'raw', '-O', 'qcow2', + str(root / 'boot.raw'), str(paths.disk)], check=True) + subprocess.run(cli + ['start', name], check=True) + time.sleep(.3) +identities = {name: {'pid': manager.paths(name).pid.read_text().strip(), + 'uuid': manager.paths(name).uuid, 'counter': counter(manager, name)} + for name in ('alpha', 'beta')} +(root / 'before.json').write_text(json.dumps(identities)) +'''.replace('CLI', repr(cli)).replace('from smoke import', 'from dev.smoke import')) + env = dict(os.environ, XDG_RUNTIME_DIR=str(xdg), HYDRA_LABWC=labwc, + HYDRA_VIEWER=viewer, HYDRA_TERMINAL=terminal, HYDRA_BRIGHTNESS=shutil.which('true'), + SHELL=shell, GSETTINGS_BACKEND='memory') + for key in ('WAYLAND_DISPLAY', 'DISPLAY', 'LABWC_PID'): + env.pop(key, None) + log = (root / 'session.log').open('w') + launcher, injector = None, None + result = {'evidence': str(root), 'physical_input_pending': True, + 'terminal': 'test-only command feeder; real recovery helper and scratch :shell', + 'backend': 'test-only wrapper around actual product launch_session and actual labwc'} + try: + launcher = subprocess.Popen(cli + ['session'], env=env, stdout=log, stderr=log) + wait_for(lambda: (root / 'recovery-ready').exists(), 'empty session did not open recovery') + first = json.loads((root / 'snapshot-0.json').read_text()) + assert first['names'] == [], 'session was not initially empty' + (root / 'recovery-input').write_text(':shell\n:refresh\n') + wait_for(lambda: (root / 'snapshot-1.json').exists(), 'product loop did not restart after :refresh', timeout=15) + second = json.loads((root / 'snapshot-1.json').read_text()) + assert second['names'] == ['alpha', 'beta'], second + assert first['pid'] != second['pid'], 'compositor was not refreshed' + logs = {name: root / (name + '.log') for name in ('alpha', 'beta')} + for name, path in logs.items(): + wait_for(lambda: path.exists() and 'set_title("HydraOS: ' + name + '")' in path.read_text(), + name + ' product viewer did not connect') + viewers = {name: json.loads((root / (name + '-viewer.json')).read_text()) for name in logs} + assert len({data['config'] for data in viewers.values()}) == 2, 'viewer settings were shared' + for data in viewers.values(): + assert data['gdk_backend'] == 'wayland' + assert data['config'].startswith(str(manager.runtime) + '/viewer-') + assert '--spice-usbredir-auto-redirect-filter=-1,-1,-1,-1,0' in data['argv'] + assert '--spice-usbredir-redirect-on-connect=-1,-1,-1,-1,0' in data['argv'] + alog, blog = logs['alpha'], logs['beta'] + display = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock')) + keyboard_env = dict(env, WAYLAND_DISPLAY=display) + injector = subprocess.Popen([args.keyboard], env=keyboard_env, stdin=subprocess.PIPE, text=True) + time.sleep(.2) + wait_for(lambda: focus_events(alog) and focus_events(alog)[-1] == 'enter', 'alpha did not focus') + + def switch(code, source_log, target_log): + before_source, before_target = focus_events(source_log), focus_events(target_log) + injector.stdin.write(f'down 29\ndown 56\ndown 42\ndown {code}\nup {code}\nup 42\nup 56\nup 29\nwait 100\n') + injector.stdin.flush() + wait_for(lambda: fresh_focus_transition(before_source, before_target, + focus_events(source_log), focus_events(target_log)), 'fresh reserved focus transition failed') + + switch(106, alog, blog) + intervals = {'alpha': measure_hidden_interval(lambda: counter(manager, 'alpha'), alog, blog)} + switch(105, blog, alog) + intervals['beta'] = measure_hidden_interval(lambda: counter(manager, 'beta'), blog, alog) + before = json.loads((root / 'before.json').read_text()) + after = {name: {'pid': manager.paths(name).pid.read_text().strip(), + 'uuid': manager.paths(name).uuid, 'counter': counter(manager, name)} + for name in ('alpha', 'beta')} + for name in before: + assert after[name]['pid'] == before[name]['pid'] and after[name]['uuid'] == before[name]['uuid'] + assert after[name]['counter'] != before[name]['counter'] + # Only the PID recorded by our own wrapper, verified against its private + # config path, may be signalled. Never inherit a host compositor PID. + command = Path('/proc') / str(second['pid']) / 'cmdline' + assert second['config_directory'].encode() in command.read_bytes() + os.kill(second['pid'], signal.SIGTERM) + assert launcher.wait(timeout=10) == 0, 'product session exit failed' + for name in before: + assert manager.status(name)['status'] == 'running' + assert manager.paths(name).pid.read_text().strip() == before[name]['pid'] + result.update(initial_names=first['names'], refreshed_names=second['names'], + compositor_pids=[first['pid'], second['pid']], before=before, after=after, + viewers=viewers, + hidden_intervals=intervals, survived_session_exit=True, + expected_xwayland_diagnostics=classify_compositor_errors((root / 'session.log').read_text()), + passed=True) + (root / 'result.json').write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps(result, indent=2)) + finally: + if injector and injector.poll() is None: + injector.terminate(); injector.wait(timeout=5) + if launcher and launcher.poll() is None: + launcher.send_signal(signal.SIGINT); launcher.wait(timeout=12) + for profile in manager.profiles(): + if manager.status(profile.name)['status'] == 'running': + manager.stop(profile.name, force=True) + log.close() + + +if __name__ == '__main__': + main() diff --git a/docs/verification.md b/docs/verification.md index d24c3fb..7b9c1af 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -12,6 +12,8 @@ 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' +nix-shell dev/tools.nix --run 'python3 dev/session-refresh-smoke.py --keyboard "$(dev/build-input-probe.sh)"' +nix-shell dev/tools.nix --run 'python3 dev/check-network.py' ``` The smoke test builds a private input helper with an ordinary evdev/pc105 US @@ -53,6 +55,48 @@ suppress viewer errors or compiler/test diagnostics. The viewer build's upstream Meson boolean-check and compiler-warning-option guidance are build noise; the four-test result, compiler errors and process exit status remain the build acceptance gate. +## Fresh-session recovery and profile changes + +A session with no profiles opens recovery with first-use guidance. Enter `:shell` +to run `hydra add NAME --iso PATH` and `hydra start NAME`; leave the shell and enter +`:refresh`. After adding or removing profiles, refresh reloads the profile list, +rebuilds workspace rules, restarts only the session's own compositor, and attaches +new isolated viewers to running guests. QEMU processes and workloads survive. +The recovery prompt can then reconnect the new names; reserved Left/Right chords +switch between their workspaces. `:quit` closes the recovery prompt. + +`dev/session-refresh-smoke.py` exercises the actual product launch loop, recovery +helper, `:shell` and `:refresh`, starting with an empty profile directory. A +test-only labwc wrapper selects private headless backends; a terminal command +feeder drives recovery and a scratch shell adds and starts two counter guests. +Actual labwc and native Wayland viewers then demonstrate fresh keyboard focus +transitions in both directions, hidden workload continuity, unchanged QEMU +PIDs/UUIDs through refresh, and guest survival after normal compositor exit. +This checks the recovery command flow, not physical terminal rendering/usability. +It leaves snapshots, recovery output, protocol logs and `result.json` in +`/tmp/hydra-session-refresh-*` and stops only its verified scratch QEMU processes. + +## Bridge identities + +New profiles persist a locally administered unicast `mac` in their TOML file. +It remains stable across stop/start, media changes and runtime socket-directory +changes. The generated address incorporates the VM disk path and user identity, +so separate profiles/users avoid QEMU's identical default MAC on a shared bridge. +Existing profiles without `mac` use the same deterministic calculation without +rewriting their file. Relocating a legacy disk or changing its owner changes +that fallback; explicitly persisting its current address retains the identity. +Explicit TOML addresses must also be locally administered unicast, and users +copying profiles must assign distinct addresses to concurrently used copies. + +`dev/check-network.py` requires unprivileged user/network/mount namespaces. It +asserts all three namespace identities changed before creating a private +`hydrabr0` and mounting a private QEMU bridge-helper ACL over `/etc` within that +namespace. Two actual scratch QEMUs must report different configured NIC MACs, +both bridge TAP ports must exist, and restart must preserve the first guest's +address. Evidence remains under `/tmp/hydra-network-*`; host bridges, host `/etc` +and existing guests are unchanged. This does not prove guest network drivers, +DHCP leases or external connectivity; those need the installed-guest harness. + ## Installed guest workflow `--iso` is optional. Attach or detach media only while the VM is stopped: diff --git a/hydra/profiles.py b/hydra/profiles.py index 8e11036..6be19f1 100644 --- a/hydra/profiles.py +++ b/hydra/profiles.py @@ -26,6 +26,7 @@ class Profile: width: int = 1280 height: int = 800 refresh_millihz: int = 0 + mac: str | None = None def __post_init__(self): validate_name(self.name) @@ -33,6 +34,12 @@ class Profile: if not isinstance(self.iso, (str, Path)) or not str(self.iso): raise ValueError('ISO must be a nonempty path or omitted') object.__setattr__(self, 'iso', Path(self.iso).expanduser().resolve()) + if self.mac is not None: + if (not isinstance(self.mac, str) or + not re.fullmatch(r'[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}', self.mac) or + int(self.mac[:2], 16) & 3 != 2): + raise ValueError('mac must be a locally administered unicast Ethernet address') + object.__setattr__(self, 'mac', self.mac.lower()) for key, upper in (('memory', 262144), ('cpus', 256)): value = getattr(self, key) if type(value) is not int or not 1 <= value <= upper: diff --git a/hydra/runtime.py b/hydra/runtime.py index cfe2e19..339d0b7 100644 --- a/hydra/runtime.py +++ b/hydra/runtime.py @@ -3,6 +3,7 @@ import contextlib import errno import fcntl +import hashlib import json import os import socket @@ -54,6 +55,14 @@ def _intel_render_node() -> Path | None: return None +def _stable_mac(paths: VMPaths) -> str: + # Disk/state identity survives restarts and runtime socket-directory changes. + # UID distinguishes owners even when a shared bridge sees the same names. + digest = bytearray(hashlib.sha256(f'{os.getuid()}:{paths.disk}'.encode()).digest()[:6]) + digest[0] = (digest[0] & 0xfc) | 0x02 + return ':'.join(f'{octet:02x}' for octet in digest) + + def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]: accel = 'kvm' if os.access('/dev/kvm', os.R_OK | os.W_OK) else 'tcg' argv = [os.environ.get('HYDRA_QEMU', 'qemu-system-x86_64'), '-name', profile.name, @@ -78,7 +87,7 @@ def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]: argv += ['-device', 'virtio-blk-pci,drive=disk'] if profile.iso is not None: argv += ['-device', 'ide-cd,drive=iso'] - argv += ['-boot', 'order=dc' if profile.iso is not None else 'order=c', '-device', 'virtio-net-pci,netdev=net', + argv += ['-boot', 'order=dc' if profile.iso is not None else 'order=c', '-device', f'virtio-net-pci,netdev=net,mac={profile.mac or _stable_mac(paths)}', '-netdev', 'user,id=net' if profile.network == 'user' else f'bridge,id=net,br={profile.bridge}', '-device', 'qemu-xhci,id=usb', '-device', 'usb-tablet', '-device', 'usb-kbd'] @@ -136,6 +145,8 @@ class Runtime: def add(self, profile: Profile) -> None: self._validate_media(profile.iso) _private_dir(self.config) + if profile.mac is None: + profile = replace(profile, mac=_stable_mac(self.paths(profile.name))) save_profile(self.config / f'{profile.name}.toml', profile) @staticmethod diff --git a/hydra/session.py b/hydra/session.py index 63c0e49..847f69b 100644 --- a/hydra/session.py +++ b/hydra/session.py @@ -49,9 +49,10 @@ def build_labwc_config(names: list[str], recovery_argv: list[str], core = ET.SubElement(root, 'core') ET.SubElement(core, 'primarySelection').text = 'no' desktops = ET.SubElement(root, 'desktops', number=str(max(1, len(names)))) - labels = ET.SubElement(desktops, 'names') - for name in names: - ET.SubElement(labels, 'name').text = name + if names: + labels = ET.SubElement(desktops, 'names') + for name in names: + ET.SubElement(labels, 'name').text = name if initial is not None: ET.SubElement(desktops, 'initial').text = str(names.index(initial) + 1) ET.SubElement(desktops, 'popupTime').text = '0' @@ -173,25 +174,45 @@ def launch_session(runtime: Runtime, *, nested=False) -> int: match = re.search(r'(\d+)\.(\d+)\.(\d+)', version.stdout) if version.returncode or not match or tuple(map(int, match.groups())) < (0, 20, 0): raise SessionError('HydraOS requires labwc >= 0.20.0 for reserved host input bindings') - profiles = runtime.profiles() - initial = next((p.name for p in profiles if runtime.status(p.name)['status'] == 'running'), None) _private_dir(runtime.runtime) - with tempfile.TemporaryDirectory(prefix='session-', dir=runtime.runtime) as tmp: - directory = Path(tmp) - config = directory / 'labwc' - config.mkdir(mode=0o700) - manifest = directory / 'session.json' - manifest.write_text(json.dumps({'config': str(runtime.config), 'state': str(runtime.state), - 'runtime': str(runtime.runtime), 'names': [p.name for p in profiles], - 'terminal': terminal, 'directory': str(directory)}), encoding='utf-8') - (config / 'rc.xml').write_text(build_labwc_config([p.name for p in profiles], - _recovery_argv(manifest, terminal), brightness, - initial=initial), encoding='utf-8') - (config / 'autostart').write_text('#!/bin/sh\n' + shlex.join(_module_argv('clients', manifest)) + ' &\n', encoding='utf-8') - # Empty environment files prevent loading unrelated /etc/xdg defaults. - (config / 'environment').touch() - env['PYTHONUNBUFFERED'] = '1' - return subprocess.run([labwc, '-C', str(config)], env=env).returncode + while True: + profiles = runtime.profiles() + initial = next((p.name for p in profiles if runtime.status(p.name)['status'] == 'running'), None) + with tempfile.TemporaryDirectory(prefix='session-', dir=runtime.runtime) as tmp: + directory = Path(tmp) + config = directory / 'labwc' + config.mkdir(mode=0o700) + manifest = directory / 'session.json' + manifest.write_text(json.dumps({'config': str(runtime.config), 'state': str(runtime.state), + 'runtime': str(runtime.runtime), 'names': [p.name for p in profiles], + 'terminal': terminal, 'directory': str(directory)}), encoding='utf-8') + (config / 'rc.xml').write_text(build_labwc_config([p.name for p in profiles], + _recovery_argv(manifest, terminal), brightness, + initial=initial), encoding='utf-8') + (config / 'autostart').write_text('#!/bin/sh\n' + shlex.join(_module_argv('clients', manifest)) + ' &\n', encoding='utf-8') + (config / 'environment').touch() + env['PYTHONUNBUFFERED'] = '1' + compositor = subprocess.Popen([labwc, '-C', str(config)], env=env) + refresh = False + try: + while True: + if (directory / 'refresh.request').exists(): + refresh = True + break + if compositor.poll() is not None: + # A requesting recovery terminal can exit before polling. + refresh = (directory / 'refresh.request').exists() + break + time.sleep(.1) + finally: + if compositor.poll() is None: + compositor.terminate() + compositor.wait(timeout=10) + if not refresh: + return compositor.returncode + # Only our compositor was stopped. Its viewers disconnect; detached + # QEMU instances retain their identity and running workloads. + def session_helper(command: str, manifest: Path) -> int: @@ -203,14 +224,22 @@ def session_helper(command: str, manifest: Path) -> int: raise SessionError('Invalid session manifest') for name in data['names']: validate_name(name) - if command not in ('clients', 'recover') and ( + if command not in ('clients', 'recover', 'refresh') and ( not command.startswith('reconnect:') or command.split(':', 1)[1] not in data['names']): raise SessionError('Invalid session helper operation') runtime = Runtime(Path(data['config']), Path(data['state']), Path(data['runtime'])) + if command == 'refresh': + fd = os.open(manifest.parent / 'refresh.request', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + os.close(fd) + print('Refreshing workspaces and viewers; VMs keep running.') + return 0 if command == 'recover': print('HydraOS local recovery. VMs keep running independently.\n' 'Ctrl+Alt+Shift+Left/Right switches workspaces; Ctrl+Alt+Shift+R releases input; Ctrl+Alt+Shift+U opens USB.\n' - 'Enter a VM name to reconnect, :shell for a host shell, :quit to close this terminal.') + 'Enter a VM name to reconnect, :shell for a host shell, :refresh to rebuild workspaces/viewers, or :quit.') + if not data['names']: + print('No VM profiles yet. Use :shell to run hydra add NAME --iso PATH and hydra start NAME.\n' + 'After adding or removing profiles, leave the shell and enter :refresh; running VMs are preserved.') while True: try: print(json.dumps([runtime.status(name) for name in data['names']], indent=2)) @@ -219,12 +248,14 @@ def session_helper(command: str, manifest: Path) -> int: return 0 if choice == ':quit': return 0 + if choice == ':refresh': + return session_helper('refresh', manifest) if choice == ':shell': subprocess.run([os.environ.get('SHELL', '/bin/sh')]) elif choice in data['names']: subprocess.Popen(_module_argv('reconnect:' + choice, manifest), start_new_session=True) else: - print('Enter a configured VM name, :shell, or :quit.') + print('Enter a configured VM name, :shell, :refresh, or :quit.') if command.startswith('reconnect:'): name = command.split(':', 1)[1] # One viewer per VM inside this session; independent host view commands diff --git a/tests/test_profiles.py b/tests/test_profiles.py index f69440c..c26b7a8 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -65,6 +65,15 @@ class ProfilesTests(unittest.TestCase): with self.subTest(field=field, value=value), self.assertRaises(ValueError): Profile('guest', self.iso, **{field: value}) + def test_explicit_mac_roundtrip_and_validation(self): + dest = self.root / 'guest.toml' + save_profile(dest, Profile('guest', mac='02:ab:cd:ef:01:23')) + self.assertEqual(load_profile(dest).mac, '02:ab:cd:ef:01:23') + for value in ('ff:00:00:00:00:01', '00:00:00:00:00:01', + '02:00:00:00:00:zz', '02:00:00:00:00', True): + with self.subTest(mac=value), self.assertRaises(ValueError): + Profile('guest', mac=value) + def test_refresh_vertical_descriptor_boundaries(self): for value in (0, 50000, 255000): path = self.root / 'guest.toml' diff --git a/tests/test_runtime.py b/tests/test_runtime.py index d06e061..4104c66 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -88,6 +88,46 @@ class RuntimeTests(unittest.TestCase): except VMError: pass + def test_mac_persisted_distinct_and_stable_across_restarts(self): + self.runtime.add(Profile('other', network='bridge')) + other = self.runtime.profile('other') + guest = self.runtime.profile('guest') + self.assertIsNotNone(guest.mac) + self.assertNotEqual(guest.mac, other.mac) + octet = int(guest.mac.split(':')[0], 16) + self.assertEqual(octet & 3, 2) # locally administered, unicast + def nic(profile, runtime): + return next(arg for arg in build_qemu_argv(profile, runtime.paths(profile.name)) + if arg.startswith('virtio-net-pci,')) + expected = nic(guest, self.runtime) + self.runtime.start('guest') + self.runtime.stop('guest', force=True) + self.runtime.start('guest') + self.runtime.stop('guest', force=True) + self.assertEqual(nic(self.runtime.profile('guest'), self.runtime), expected) + changed_runtime = Runtime(self.runtime.config, self.runtime.state, self.root / 'different-run') + self.assertEqual(nic(changed_runtime.profile('guest'), changed_runtime), expected) + self.runtime.media('guest', None) + self.assertEqual(self.runtime.profile('guest').mac, guest.mac) + + def test_legacy_profiles_gain_stable_owner_specific_argv_mac(self): + file = self.runtime.config / 'legacy.toml' + save_profile(file, Profile('legacy', network='bridge')) + original = file.read_bytes() + legacy = self.runtime.profile('legacy') + self.assertIsNone(legacy.mac) + paths = self.runtime.paths('legacy') + def mac(): + return next(arg for arg in build_qemu_argv(legacy, paths) if arg.startswith('virtio-net-pci,')) + with patch('os.getuid', return_value=1000): + first = mac() + self.assertEqual(mac(), first) + self.assertNotEqual(first, next(arg for arg in build_qemu_argv( + legacy, self.runtime.paths('other')) if arg.startswith('virtio-net-pci,'))) + with patch('os.getuid', return_value=1001): + self.assertNotEqual(mac(), first) + self.assertEqual(file.read_bytes(), original) + def test_disk_boot_omits_media_and_requests_display(self): profile = Profile('guest', width=1920, height=1080, refresh_millihz=144000, graphics='software') @@ -145,7 +185,7 @@ class RuntimeTests(unittest.TestCase): self.assertIn('disable-agent-file-xfer=on', spice) self.assertIn('run,,comma', spice) self.assertIn('virtio-blk-pci,drive=disk', argv) - self.assertIn('virtio-net-pci,netdev=net', argv) + self.assertTrue(any(arg.startswith('virtio-net-pci,netdev=net,mac=') for arg in argv)) self.assertNotIn('-vnc', argv) self.assertNotIn('-virtfs', argv) diff --git a/tests/test_session.py b/tests/test_session.py index 80bb4e2..91c42bd 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -55,6 +55,11 @@ class SessionTests(unittest.TestCase): self.assertEqual(tree.findall('./mouse/context/mousebind/action'), []) self.assertEqual(tree.findtext('./libinput/device/tap'), 'yes') + def test_empty_session_has_one_workspace_without_empty_names(self): + tree = ET.fromstring(session.build_labwc_config([], ['foot'])) + self.assertEqual(tree.find('./desktops').get('number'), '1') + self.assertIsNone(tree.find('./desktops/names')) + def test_viewer_argv_is_private_safe_and_manual_usb_only(self): paths = self.manager.paths('alpha') argv = session.build_viewer_argv(paths, '/tool path/viewer') @@ -173,6 +178,45 @@ else: self.assertEqual(main(['_session-helper', 'recover', str(manifest)]), 1) self.assertIn('manifest', error.getvalue().lower()) + def test_empty_launch_refresh_rebuilds_profile_snapshot(self): + manager = Runtime(self.root / 'empty-config', self.root / 'empty-state', self.root / 'empty-run') + capture = self.root / 'refresh-capture.json' + compositor = self.root / 'refresh-labwc' + compositor.write_text('#!' + os.sys.executable + '\n' + r'''import json, os, subprocess, sys +from pathlib import Path +if '--version' in sys.argv: + print('labwc 0.20.2') + sys.exit(0) +config = Path(sys.argv[sys.argv.index('-C') + 1]) +manifest = config.parent / 'session.json' +data = json.loads(manifest.read_text()) +capture = Path(os.environ['CAPTURE']) +seen = json.loads(capture.read_text()) if capture.exists() else [] +seen.append({'names': data['names'], 'xml': (config / 'rc.xml').read_text()}) +capture.write_text(json.dumps(seen)) +if len(seen) == 1: + launcher = os.environ['HYDRA_SOURCE_LAUNCHER'] + for name in ('first', 'second'): + subprocess.run([sys.executable, launcher, '--config', data['config'], '--state', data['state'], + '--runtime', data['runtime'], 'add', name], check=True) + subprocess.run([sys.executable, launcher, '_session-helper', 'recover', str(manifest)], + input=':refresh\n', text=True, check=True) + sys.exit(67) +sys.exit(0) +''') + compositor.chmod(0o700) + with patch.dict(os.environ, {'HYDRA_LABWC': str(compositor), 'HYDRA_TERMINAL': str(compositor), + 'HYDRA_BRIGHTNESS': str(compositor), 'CAPTURE': str(capture), + 'HYDRA_SOURCE_LAUNCHER': str(Path('bin/hydra').resolve())}), contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(session.launch_session(manager), 0) + seen = json.loads(capture.read_text()) + self.assertEqual([entry['names'] for entry in seen], [[], ['first', 'second']]) + tree = ET.fromstring(seen[1]['xml']) + self.assertEqual(tree.find('desktops').get('number'), '2') + self.assertEqual([rule.get('title') for rule in tree.findall('./windowRules/windowRule')], + ['HydraOS: first', 'HydraOS: second']) + self.assertFalse(manager.paths('first').disk.exists()) + def test_no_running_vms_opens_recovery_without_start_or_stop(self): terminal = self.root / 'terminal' capture = self.root / 'recovery-argv.json'