diff --git a/vmbox/.pre-commit-config.yaml b/vmbox/.pre-commit-config.yaml index 223673a..a4df909 100644 --- a/vmbox/.pre-commit-config.yaml +++ b/vmbox/.pre-commit-config.yaml @@ -21,6 +21,6 @@ repos: hooks: - id: file-length-cap name: enforce 250-line cap - entry: bash -c 'for f in "$@"; do n=$(wc -l < "$f"); if [ "$n" -gt 250 ]; then echo "$f: $n lines exceeds the 250-line cap"; exit 1; fi; done' -- + entry: bash -c 'for f in "$@"; do n=$(wc -l < "$f"); if [ "$n" -gt 250 ]; then echo "$f has $n lines, over the 250-line cap"; exit 1; fi; done' -- language: system files: ^vmbox/ diff --git a/vmbox/DOCS-rtc-wake.md b/vmbox/DOCS-rtc-wake.md new file mode 100644 index 0000000..151e5a7 --- /dev/null +++ b/vmbox/DOCS-rtc-wake.md @@ -0,0 +1,178 @@ +# RTC wake emulation (`vm wake`) and UEFI mode + +Tests the "PC wakes itself" flow (wake-alarm's `rtcwake -m no -t ` + +hibernate or poweroff) in a sandbox: + +```bash +vm new rw --uefi # or without --uefi (SeaBIOS, default) +VMBOX_MEM=2048 vm hibernate-setup rw # swap + resume= (overlay only) +vm wake rw --accel 10 -- 'sudo rtcwake -m no -s 120 && sudo systemctl hibernate' +bash tests/rtcwake_e2e.sh [--uefi] # the end-to-end proof, see below +``` + +Run anything that boots a guest under +`~/.claude/scripts/capped.sh` (`CAP_MEM=4G CAP_CPU_PCT=20`) with +`VMBOX_MEM=2048`: a 4 GiB guest does not fit a 4 GiB cap. + +## Why the firmware half is emulated + +Measured on this host (qemu 11.1.1, edk2-ovmf 202608, q35, 2026-10-01). The +guest armed an alarm, went down, and we waited well past it: + +| Guest goes to | Firmware | CMOS after the alarm time | QEMU | +|---|---|---|---| +| S3 (`rtcwake -m mem`) | SeaBIOS | — | `SUSPEND`, then **`WAKEUP` 20 s later** — native | +| S5 (`systemctl poweroff`) | SeaBIOS | REG_C = 0xB0 (alarm flag set) | stays `shutdown` | +| S4 (`rtcwake -m disk`) | SeaBIOS | REG_C = 0xB0 | stays `shutdown` | +| S5 | OVMF | REG_C = 0xB0 | stays `shutdown` | +| S4 (`systemctl hibernate`) | OVMF | REG_C = 0xB0 | stays `shutdown` | + +In every S4/S5 row the guest had also set `RTC_EN` in ACPI `PM1_EN` +(0x0520 / 0x0720) and AIE in REG_B — everything a real chipset needs. QEMU +only acts on a wakeup request while the machine is *suspended* (S3); a +powered-off machine has no wake path at all. So `vm wake` plays the board: +it reads the alarm, waits, and presses the power button. + +## VM artefacts around a resume + +All four were found by the e2e test, each broke a resume the guest itself +had done correctly, and none exists on real hardware. A hibernate resume in +QEMU is always a relaunch of the process, so any device state that lived +only in the old process is gone when the guest's drivers pick up again: + +- **kvmclock.** A KVM guest's persistent clock is kvmclock, i.e. the HOST's + wall clock. A cold boot still follows the RTC (rtc_cmos `hctosys`), but a + resume does not: powered on with its RTC at the alarm instant, a guest + resumed **102 s before its own alarm**. Real hardware reads the CMOS on + resume. `vm hibernate-setup` therefore adds `no-kvmclock`, and `vm wake` + fails with exit 14 (`clock-not-from-rtc`) when the guest clock is behind + the alarm it was powered on at, instead of passing silently. +- **TCO watchdog.** Every resumed guest was reset ~20 s in + (`WATCHDOG action=reset`; the native S3 wake hit the same ~34 s in). + Measured on the ICH9 TCO block at 0x660: `TCO_TMR` (0x672) reads 0x0032 + after a cold boot — the driver's 30 s heartbeat at 0.6 s/tick — and + 0x0004, QEMU's 2.4 s reset default, right after a resume, while the + resumed driver still reports `timeout=30`. A fresh QEMU process lost the + value, the restored driver does not reprogram it, and its 15 s pings + cannot beat a 2.4 s timer. `vm wake` powers on with + `-action watchdog=none` (`VMBOX_WATCHDOG_ACTION`): expiries are still + logged in `events.jsonl` (one per ping, every 15 s), but ignored. The + guest's own watchdog setup is untouched. +- **9p host share.** Observed: after a resume every access to + `/mnt/hostrepo` blocked, and the next poweroff sat 4.5 min in "A stop job + is running for /mnt/hostrepo"; unmounting before and remounting after + fails with "no channels available for device hostrepo", and unbinding the + device after the resume hangs. Inferred, not verified: 9pnet_virtio does + not re-create its virtqueue on restore. What works (measured): + `hibernate-setup`'s system-sleep hook unmounts and unbinds the device + before hibernating, then rebinds and remounts it after. +- **Display (NOT fixed).** virtio-gpu fences never complete after a resume: + Xorg — the resumed one and a freshly started one alike — sits in + `dma_fence_default_wait`. Anything that needs X after a resume cannot be + tested here. `guest/vmbox-x11.sh` now bounds its one X call + (`i3 --get-socketpath`), which had hung every later `vm run`; + `hibernate-setup` installs that copy, since older base images bake in the + unbounded one. + +## What `vm wake` does + +1. Boots the sandbox if needed, then `set-action shutdown=pause` over QMP — the + runtime form of `-no-shutdown`. Launch defaults are untouched; only this + run's machine pauses instead of exiting when the guest goes down. +2. Runs the guest command over ssh and waits (bounded, + `VMBOX_WAKE_DOWN_TIMEOUT`, 240 s) for QMP `SHUTDOWN`. `SUSPEND_DISK` right + before it means hibernate (ACPI S4); anything else (reboot, panic) is exit 13. +3. Reads the **stopped** guest's CMOS through HMP port I/O + (`lib/rtc_alarm.py`): alarm sec/min/hour (0x01/0x03/0x05), REG_B (AIE, + BCD/binary, 12/24 h), `PM1_EN` at 0x602, and the RTC itself via + `qom-get /machine rtc-time`. Register C is never read — reading clears it. + QEMU's FADT has no day alarm ("alarms up to one day"), so the wake instant + is the next time the RTC matches h:m:s — the hardware's own comparison. +4. AIE off → `NO WAKE ARMED`, the machine is left off, exit 9. +5. Waits `ceil(lead / accel)` real seconds (`--accel`, default 1; refuses with + exit 12 beyond `--max-wait`, default 900 s). +6. Quits QEMU and relaunches it with `-rtc base=` (one-shot + `VMBOX_RTC_ONCE`; the sandbox's `--rtc` pin is never rewritten), on the + **same** RAM, SMP and firmware the guest went down on — read from the + running QEMU's `/proc//cmdline`, because a resume on different RAM + discards the image. For S5 that is a cold boot; for S4 the kernel resumes + from swap. Exit 11 if ssh does not answer within 180 s. +7. Prints `RTCWAKE key=value` lines (below) and `WAKE: … after its alarm`; + exit 14 if the guest's clock came back behind the alarm (see below). + +Relaunching instead of resuming the paused process is faithful, not a +shortcut: in S4/S5 only the disk and the CMOS survive on real hardware, and +both are carried over (the overlay is the disk; the RTC base is the clock). + +| Key | Meaning | +|---|---| +| `down` | `poweroff` or `hibernate` | +| `rtc_at_down` | guest RTC when the harness read the CMOS | +| `cmos_regs`, `aie`, `rtc_en` | raw registers and the two enable bits | +| `armed_epoch`, `armed_utc` | decoded alarm (RTC read as UTC, like `rtcwake`) | +| `lead_guest_s` | alarm − RTC at read time | +| `accel`, `waited_real_s` | acceleration and the real wait | +| `poweron_rtc` | RTC the machine was powered on with | +| `back_after_real_s` | relaunch → ssh answering, wall clock | +| `up_after_alarm_guest_s` | guest `date +%s` at first ssh − alarm | +| `result` | `woke` (0), `not-armed` (9), `never-down` (10), `not-back` (11), `too-far` (12), `wrong-stop:*` (13), `clock-not-from-rtc` (14) | + +## UEFI mode + +`vm new --uefi` stores `firmware=uefi` in meta and copies +`OVMF_VARS.4m.fd` to the sandbox's `efivars.fd`; `vm run --uefi …` +overrides one launch (a BIOS sandbox gets its varstore on first UEFI boot). +Paths come from edk2-ovmf's descriptor +`/usr/share/qemu/firmware/60-edk2-ovmf-x86_64-4m.json` +(`VMBOX_OVMF_CODE`/`VMBOX_OVMF_VARS` override). A missing OVMF is an error, +never a silent BIOS boot. + +- The cloud image boots both ways unmodified: its ESP (`/efi`) carries + `EFI/BOOT/BOOTX64.EFI`, the only loader a fresh varstore tries. +- The varstore persists across launches (systemd keeps `HibernateLocation` + there) and is re-copied by `vm reset`. +- `vm list` shows a `FW` column. The base image is unchanged. + +## `vm hibernate-setup` + +The base image has a 512 MiB btrfs swapfile and a systemd initramfs, but no +`resume=`: a hibernated BIOS guest cold-boots and logs `PM: Image not found`. +`guest/hibernate-setup.sh` grows `/swap/swapfile` to RAM + 512 MiB +(`btrfs filesystem mkswapfile`), adds `resume=UUID=… resume_offset=… +no-kvmclock` to `GRUB_CMDLINE_LINUX_DEFAULT`, regenerates `grub.cfg` (shared +by the BIOS and EFI GRUB), reboots, and proves it: those args in +`/proc/cmdline` and logind `CanHibernate` = `yes`. Overlay only — never +`vm build --force`. + +`vm run 'sudo systemctl hibernate'` now reports `VERDICT: hibernated` +(exit 8) instead of a DIRTY shutdown, and skips the exit-code recovery boot, +which would have consumed the image. + +## What this proves, and what only the host can + +Proven in the VM (by `tests/rtcwake_e2e.sh`): + +- the wake instant is armed correctly: the alarm decoded from the CMOS + equals the guest's own `/sys/class/rtc/rtc0/wakealarm`; +- it **survives the guest going down**, for poweroff and for hibernate — the + value is read *after* the stop, with AIE and `RTC_EN` still set; +- after a power-on at that instant the guest comes back: a boot-time unit + runs after the alarm (poweroff), or the same boot resumes with a + pre-hibernate process alive and the post-resume hook run (hibernate); +- an alarm disabled before going down leaves the machine off (exit 9). + +Not proven — host only: + +- **that real firmware wakes at all.** That is the whole emulated part. The + host's S4 wake was verified separately (wake-alarm + `DOCS-wake-enforcement.md`); S5 stays unverified until tested on the host. +- firmware quirks: a BIOS setting that disables RTC wake, an RTC kept in + local time, a board that ignores `RTC_EN` from S5, a day-of-month alarm + (QEMU has none, so alarms > 24 h ahead cannot be modelled); +- boot/resume *duration* on real hardware, and the real disk/ESP layout; +- anything graphical after a resume (the guest display is dead, above), and + how real GPU/audio/USB drivers come back from S4; +- the guest's wall clock after resume follows the RTC only because of + `no-kvmclock`; on the host it is the firmware's CMOS, as it should be. + +Report a green run as "passed in vmbox, not verified on the host". diff --git a/vmbox/README.md b/vmbox/README.md index f7ef6f4..10caafc 100644 --- a/vmbox/README.md +++ b/vmbox/README.md @@ -131,8 +131,11 @@ Two things that installer taught us, both worth knowing before you run it: | Command | Effect | |---|---| | `vm build [--force]` | Build/rebuild the golden image | -| `vm new [--rtc ]` | Create a sandbox | -| `vm run ` | Run a command, print the verdict | +| `vm new [--rtc ] [--uefi]` | Create a sandbox (`--uefi`: OVMF + its own varstore) | +| `vm run [--uefi\|--bios] ` | Run a command, print the verdict (hibernate = exit 8) | +| `vm start ` | Boot and wait for ssh, nothing else | +| `vm wake [--accel N] -- ` | Run a command that arms the RTC and powers off / hibernates; power the guest on when its CMOS alarm fires | +| `vm hibernate-setup ` | Swap + `resume=` so `systemctl hibernate` works and resumes | | `vm ssh ` | Interactive shell (starts the VM if needed) | | `vm screenshot [out.png]` | Capture the screen (locker/X11 tests) — verified: real 1280x800 PNG of the guest's i3 session, no viewer installed | | `vm lightdm ` | Switch the sandbox to the host's login topology: lightdm autologin → i3, tty1 a plain getty. Reboots the guest and proves `lightdm.service` is active with an Xorg it owns | @@ -157,6 +160,17 @@ Both mutate a live overlay only. The base image is never rebuilt for this: backs onto the old file's *name*, which is silent qcow2 corruption, not a loud refusal. +## The PC wakes itself: `vm wake` and UEFI mode + +QEMU never powers a guest on from S5 or resumes it from S4 when the CMOS +alarm fires (measured, SeaBIOS and OVMF; only S3 wakes natively). `vm wake` +plays the firmware: it keeps the stopped guest's CMOS alive, reads the armed +alarm out of it over QMP, waits (optionally `--accel`erated) and powers the +guest on at that instant -- a cold boot after poweroff, a resume after +hibernate. `tests/rtcwake_e2e.sh` proves the arming, the survival and the +boot/resume units; that real firmware wakes stays a host-only question. +Details, measurements and limits: [DOCS-rtc-wake.md](DOCS-rtc-wake.md). + ## Out of scope A VM still cannot cover adb/phone tests (~36 files), real GPU monitors, and @@ -165,9 +179,10 @@ stay on the host or stay untested. ## Tests -`bats tests/test_vmbox.bats` — 39 host-side unit tests for name validation, -meta handling, serial rotation and the full verdict table. They do not boot a -VM; booting is covered by the destructive end-to-end demo above. +`bats tests/test_vmbox.bats tests/test_rtcwake.bats` — host-side unit tests +for name validation, meta handling, serial rotation, the full verdict table, +firmware selection and the CMOS alarm decoder. They do not boot a VM; booting +is covered by the end-to-end demos (`tests/*_demo.sh`, `tests/rtcwake_e2e.sh`). ## Speed diff --git a/vmbox/bin/vm b/vmbox/bin/vm index 94f4aa4..c0c4555 100755 --- a/vmbox/bin/vm +++ b/vmbox/bin/vm @@ -19,8 +19,17 @@ usage() { Usage: vm [args] build Build the golden base image (once, ~10 min) - new [--rtc ] Create a sandbox (instant; thin overlay) - run Run a command in the sandbox, report the verdict + new [--rtc ] [--uefi] + Create a sandbox (instant; thin overlay) + run [--uefi|--bios] + Run a command in the sandbox, report the verdict + start Boot the sandbox and wait until ssh answers + wake [--accel N] [--max-wait S] [--] + Run a command that arms the RTC and powers off or + hibernates; power the guest back on when its + CMOS alarm fires (see DOCS-rtc-wake.md) + hibernate-setup [--swap-mb N] + Swap + resume= so the guest can hibernate/resume ssh [args...] Interactive shell in the sandbox scp Copy a host file into the sandbox share Expose a host dir to sandboxes (read-only bind) @@ -40,6 +49,8 @@ Usage: vm [args] VM's meta and re-applied on EVERY launch: the shutdown scripts are gated on a 21:00-05:00 window, so an unpinned clock makes their tests nondeterministic. + --uefi Boot with OVMF instead of SeaBIOS. On `new` it is stored in meta + (with a per-sandbox varstore); on `run` it is this launch only. Examples: vm new demo --rtc 2026-08-22T20:55:00 @@ -70,18 +81,30 @@ cmd_scp() { ok "copied $src -> $name:$dest" } +# Boot without running anything. VMBOX_START_TIMEOUT bounds the ssh wait. +cmd_start() { + local name + name="$(validate_vm_name "${1:-}")" + require_vm "$name" + vm_is_running "$name" && { log "sandbox '$name' is already running"; return 0; } + launch_vm "$name" >/dev/null + vm_wait_ssh "$name" "${VMBOX_START_TIMEOUT:-150}" || + die "sandbox '$name' did not become reachable" + ok "sandbox '$name' is up" +} cmd_list() { [[ -d "$VMBOX_VMS_DIR" ]] || { echo "no sandboxes yet (create one: vm new )"; return 0; } - local found=0 d name state rtc - printf '%-20s %-12s %s\n' "NAME" "STATE" "RTC" + local found=0 d name state rtc fw + printf '%-20s %-12s %-6s %s\n' "NAME" "STATE" "FW" "RTC" for d in "$VMBOX_VMS_DIR"/*/; do [[ -d "$d" ]] || continue found=1 name="$(basename "$d")" if vm_is_running "$name"; then state="running"; else state="stopped"; fi rtc="$(meta_get "$name" rtc 2>/dev/null || echo '-')" - printf '%-20s %-12s %s\n' "$name" "$state" "${rtc:--}" + fw="$(meta_get "$name" firmware 2>/dev/null || echo bios)" + printf '%-20s %-12s %-6s %s\n' "$name" "$state" "${fw:-bios}" "${rtc:--}" done (( found )) || echo "(none)" } @@ -96,6 +119,11 @@ main() { reset) source "$VMBOX_LIB_DIR/overlay.sh"; overlay_reset "$@" ;; rm) source "$VMBOX_LIB_DIR/overlay.sh"; overlay_rm "$@" ;; run) require_host_deps; source "$VMBOX_LIB_DIR/run.sh"; run_in_vm "$@" ;; + start) require_host_deps; source "$VMBOX_LIB_DIR/launch.sh" + source "$VMBOX_LIB_DIR/ssh.sh"; cmd_start "$@" ;; + wake) require_host_deps; source "$VMBOX_LIB_DIR/rtcwake.sh"; cmd_wake "$@" ;; + hibernate-setup) + require_host_deps; source "$VMBOX_LIB_DIR/hibernate.sh"; cmd_hibernate_setup "$@" ;; ssh) source "$VMBOX_LIB_DIR/ssh.sh"; vm_ssh_interactive "$@" ;; scp) source "$VMBOX_LIB_DIR/ssh.sh"; cmd_scp "$@" ;; screenshot) source "$VMBOX_LIB_DIR/screenshot.sh"; vm_screenshot "$@" ;; diff --git a/vmbox/guest/hibernate-setup.sh b/vmbox/guest/hibernate-setup.sh new file mode 100644 index 0000000..e960a95 --- /dev/null +++ b/vmbox/guest/hibernate-setup.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# ============================================================================ +# Runs INSIDE a sandbox, as root, via `vm hibernate-setup`. Makes +# `systemctl hibernate` both allowed and resumable, in the overlay only. +# +# The base image already has a 512 MiB btrfs swapfile and a systemd-based +# initramfs (which resumes from `resume=` on its own -- no mkinitcpio hook). +# What it lacks: +# - swap big enough for the image (systemd refuses when swap < used RAM); +# - resume=/resume_offset= on the kernel cmdline. Without them a BIOS guest +# cold-boots and silently discards the image ("PM: Image not found"). +# UEFI guests can also resume via systemd's HibernateLocation variable, +# but the cmdline works for both, so both get it. +# - no-kvmclock. With kvmclock the kernel's persistent clock is the HOST's +# wall clock, so a resumed guest ignores the RTC: `vm wake` powered a +# guest on with its RTC at the alarm instant and it resumed 102 s BEFORE +# its own alarm (measured 2026-10-01). A cold boot is unaffected (rtc_cmos +# hctosys sets the clock), which is why only hibernate exposed it. Real +# hardware reads the CMOS on resume; this makes the guest do the same. +# - a sleep hook that unmounts the 9p host share before hibernating and +# remounts it after. Its fids belong to the QEMU process that dies at S4; +# after the relaunch every access to /mnt/hostrepo blocked, and the next +# poweroff sat 4.5 min in "A stop job is running for /mnt/hostrepo" +# (measured). Real machines have no 9p, so this only removes a VM artefact. +# - the current /etc/profile.d/vmbox-x11.sh (if passed in as $2): the one +# baked into older base images makes an unbounded X call, and X is +# wedged after a resume, so every `vm run` after one hung. +# +# Usage: hibernate-setup.sh [swap-MiB [vmbox-x11.sh]] (0 = RAM + 512 MiB) +# ============================================================================ + +set -euo pipefail + +readonly SWAPFILE=/swap/swapfile +readonly GRUB_DEFAULT=/etc/default/grub +swap_mb="${1:-0}" +x11_profile="${2:-}" + +mem_mb() { + local key value _ + while read -r key value _; do + [[ "$key" == MemTotal: ]] && { printf '%d' $(( value / 1024 )); return 0; } + done < /proc/meminfo + return 1 +} + +ensure_swap() { + local want_mb="$1" have_mb=0 + [[ -f "$SWAPFILE" ]] && have_mb=$(( $(stat -c %s "$SWAPFILE") / 1048576 )) + if (( have_mb < want_mb )); then + echo "hibernate-setup: swapfile ${have_mb} MiB -> ${want_mb} MiB" + swapoff "$SWAPFILE" 2>/dev/null || true + rm -f "$SWAPFILE" + install -d -m 700 "$(dirname "$SWAPFILE")" + # mkswapfile sets NOCOW and allocates contiguously, which hibernation + # to a btrfs swapfile requires. + btrfs filesystem mkswapfile --size "${want_mb}m" "$SWAPFILE" >/dev/null + fi + swapon --show=NAME --noheadings | grep -qx "$SWAPFILE" || swapon "$SWAPFILE" + grep -qE "^${SWAPFILE}[[:space:]]" /etc/fstab || + printf '%s none swap defaults 0 0\n' "$SWAPFILE" >> /etc/fstab +} + +set_resume_cmdline() { + local uuid offset + uuid="$(findmnt -no UUID -T "$SWAPFILE")" + offset="$(btrfs inspect-internal map-swapfile -r "$SWAPFILE")" + [[ -n "$uuid" && "$offset" =~ ^[0-9]+$ ]] || + { echo "hibernate-setup: cannot locate $SWAPFILE (uuid='$uuid' offset='$offset')" >&2; exit 1; } + # Idempotent: drop any previous resume tokens, then append the current ones. + sed -i -E '/^GRUB_CMDLINE_LINUX_DEFAULT=/{ + s/ ?(resume(_offset)?=[^ "]*|no-kvmclock)//g + s/"$/ resume=UUID='"$uuid"' resume_offset='"$offset"' no-kvmclock"/ + }' "$GRUB_DEFAULT" + grep -q "resume_offset=$offset" "$GRUB_DEFAULT" || + { echo "hibernate-setup: failed to edit $GRUB_DEFAULT" >&2; exit 1; } + grub-mkconfig -o /boot/grub/grub.cfg >/dev/null 2>&1 + grep -q "resume_offset=$offset" /boot/grub/grub.cfg || + { echo "hibernate-setup: grub.cfg lacks the resume args" >&2; exit 1; } + echo "hibernate-setup: resume=UUID=$uuid resume_offset=$offset no-kvmclock (effective next boot)" +} + +install_9p_hook() { + # Unmount AND unbind while the old QEMU still answers, rebind after + # resume. Remounting alone fails ("no channels available for device + # hostrepo") and unbinding after the resume hangs -- measured; the likely + # cause, not verified, is that 9pnet_virtio does not rebuild its + # virtqueue on restore. /run is tmpfs, i.e. in the hibernation image, so + # the device name survives the trip. + cat > /usr/lib/systemd/system-sleep/vmbox-9p <<'HOOK' +#!/bin/sh +# vmbox: the 9p share cannot survive QEMU being relaunched (see hibernate-setup.sh). +drv=/sys/bus/virtio/drivers/9pnet_virtio +state=/run/vmbox-9p-dev +case "$1/$2" in + pre/hibernate|pre/hybrid-sleep|pre/suspend-then-hibernate) + mountpoint -q /mnt/hostrepo && ! timeout 10 umount /mnt/hostrepo && exit 0 + for d in "$drv"/virtio*; do + [ "$(tr -d '\0' < "$d/mount_tag" 2>/dev/null)" = hostrepo ] || continue + echo "${d##*/}" > "$drv/unbind" && echo "${d##*/}" > "$state" + done ;; + post/*) + [ -s "$state" ] && cat "$state" > "$drv/bind" && rm -f "$state" + mountpoint -q /mnt/hostrepo || timeout 10 mount /mnt/hostrepo ;; +esac +exit 0 +HOOK + chmod 755 /usr/lib/systemd/system-sleep/vmbox-9p +} + +main() { + [[ $EUID -eq 0 ]] || { echo "hibernate-setup: run as root" >&2; exit 1; } + [[ "$swap_mb" =~ ^[0-9]+$ ]] || { echo "hibernate-setup: swap size must be MiB" >&2; exit 1; } + (( swap_mb > 0 )) || swap_mb=$(( $(mem_mb) + 512 )) + ensure_swap "$swap_mb" + install_9p_hook + [[ -z "$x11_profile" ]] || install -m 644 "$x11_profile" /etc/profile.d/vmbox-x11.sh + set_resume_cmdline +} + +main "$@" diff --git a/vmbox/guest/vmbox-x11.sh b/vmbox/guest/vmbox-x11.sh index a589ce7..ba7b9de 100644 --- a/vmbox/guest/vmbox-x11.sh +++ b/vmbox/guest/vmbox-x11.sh @@ -54,6 +54,9 @@ fi # i3 tools look here when I3SOCK is unset; setting it explicitly avoids a # second round of X round-trips in tests that shell out repeatedly. if [ -n "${DISPLAY:-}" ] && [ -z "${I3SOCK:-}" ] && command -v i3 >/dev/null 2>&1; then - I3SOCK="$(i3 --get-socketpath 2>/dev/null || true)" + # Bounded: this is an X round-trip, and after a hibernate resume the + # guest's virtio-gpu never completes a fence, so Xorg is wedged and an + # unbounded call hung every later `vm run` (measured 2026-10-01). + I3SOCK="$(timeout 2 i3 --get-socketpath 2>/dev/null || true)" if [ -n "$I3SOCK" ]; then export I3SOCK; else unset I3SOCK; fi fi diff --git a/vmbox/install.sh b/vmbox/install.sh index 4ef759d..0bae2c5 100755 --- a/vmbox/install.sh +++ b/vmbox/install.sh @@ -14,7 +14,7 @@ readonly SCRIPT_DIR readonly LINK_DIR="${LINK_DIR:-$HOME/.local/bin}" # xorriso ships in libisoburn -- there is no package named "xorriso". -readonly -a PACKAGES=(qemu-base libisoburn openssh python) +readonly -a PACKAGES=(qemu-base libisoburn openssh python jq edk2-ovmf) if [[ $EUID -eq 0 ]]; then echo "Do not run install.sh as root: it links into \$HOME and would" >&2 diff --git a/vmbox/lib/firmware.sh b/vmbox/lib/firmware.sh new file mode 100644 index 0000000..ac38975 --- /dev/null +++ b/vmbox/lib/firmware.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# ============================================================================ +# vmbox: optional UEFI firmware (OVMF) for a sandbox. +# +# The default stays SeaBIOS: every existing sandbox and test was measured on +# it, and nothing here changes a sandbox that did not opt in. UEFI is chosen +# per sandbox (`vm new --uefi`, stored in meta) or per launch +# (`VMBOX_FIRMWARE=uefi|bios`, which `vm run --uefi` sets). +# +# The cloud image boots both ways unmodified: it carries an ESP at /efi with +# the removable-media loader EFI/BOOT/BOOTX64.EFI, which is the ONLY path a +# fresh varstore tries -- so no boot entry has to be created first. +# ============================================================================ + +# Sourced by both launch.sh and overlay.sh, and launch_vm sources overlay.sh +# at call time -- so a second source must be a no-op, not a readonly error. +[[ -n "${VMBOX_FIRMWARE_SOURCED:-}" ]] && return 0 +readonly VMBOX_FIRMWARE_SOURCED=1 + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +# edk2-ovmf's own descriptor for the 4 MB non-secure-boot build on q35. Read +# rather than hard-coded so a package layout change moves us with it. +readonly VMBOX_OVMF_DESCRIPTOR="${VMBOX_OVMF_DESCRIPTOR:-/usr/share/qemu/firmware/60-edk2-ovmf-x86_64-4m.json}" + +# Per-sandbox NVRAM. It must persist across launches: systemd stores the +# hibernation resume location (HibernateLocation) here, so a fresh copy per +# boot would silently turn every UEFI resume into a cold boot. +vm_efivars() { printf '%s/%s/efivars.fd' "$VMBOX_VMS_DIR" "$1"; } + +# $1 = executable | nvram-template. Env overrides win over the descriptor. +_firmware_file() { + local kind="$1" path="" + case "$kind" in + executable) path="${VMBOX_OVMF_CODE:-}" ;; + nvram-template) path="${VMBOX_OVMF_VARS:-}" ;; + esac + if [[ -z "$path" ]]; then + [[ -r "$VMBOX_OVMF_DESCRIPTOR" ]] || + die "no OVMF descriptor at $VMBOX_OVMF_DESCRIPTOR -- install edk2-ovmf ($VMBOX_ROOT/install.sh)" + # Checked here, not in require_host_deps: only UEFI needs jq, and a + # BIOS-only host must keep working without it. + command -v jq >/dev/null || die "UEFI mode needs jq -- run: $VMBOX_ROOT/install.sh" + path="$(jq -er --arg k "$kind" '.mapping[$k].filename // empty' "$VMBOX_OVMF_DESCRIPTOR")" || + die "OVMF descriptor $VMBOX_OVMF_DESCRIPTOR has no mapping.$kind.filename" + fi + [[ -r "$path" ]] || die "OVMF $kind not readable: $path" + printf '%s' "$path" +} + +# Effective firmware for the next launch: per-launch env, then meta, then bios. +firmware_of() { + local name="$1" fw + fw="${VMBOX_FIRMWARE:-$(meta_get "$name" firmware 2>/dev/null || true)}" + fw="${fw:-bios}" + case "$fw" in + bios|uefi) printf '%s' "$fw" ;; + *) die "unknown firmware '$fw' (expected bios or uefi)" ;; + esac +} + +# Fresh varstore from the template. Called by `vm new --uefi`, by `vm reset` +# (a "pristine" sandbox must not keep the last run's boot entries or +# HibernateLocation), and lazily the first time a BIOS sandbox boots as UEFI. +firmware_init_vars() { + local name="$1" vars template + vars="$(vm_efivars "$name")" + # Explicit exits: callers use `|| firmware_init_vars`, and set -e is off + # inside anything called from an || list. + template="$(_firmware_file nvram-template)" || exit 1 + install -m 644 "$template" "$vars" || die "could not create $vars" +} + +# Append the sandbox's firmware args to the array named by $2; none for bios. +# A nameref, not printed output read back through a process substitution: a +# `die` inside one is swallowed, and a missing OVMF would then quietly boot +# the sandbox as BIOS -- the opposite of what was asked for. +firmware_qemu_args() { + local name="$1" fw code vars + local -n _fw_out="$2" + fw="$(firmware_of "$name")" || exit 1 + [[ "$fw" == uefi ]] || return 0 + code="$(_firmware_file executable)" || exit 1 + vars="$(vm_efivars "$name")" + [[ -f "$vars" ]] || firmware_init_vars "$name" + # Unit 0 read-only: the firmware code is a shared system file. Unit 1 is + # this sandbox's own NVRAM copy, writable. + _fw_out+=( + -drive "if=pflash,format=raw,unit=0,readonly=on,file=$code" + -drive "if=pflash,format=raw,unit=1,file=$vars" + ) +} diff --git a/vmbox/lib/hibernate.sh b/vmbox/lib/hibernate.sh new file mode 100644 index 0000000..eff4ecb --- /dev/null +++ b/vmbox/lib/hibernate.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# ============================================================================ +# vmbox: `vm hibernate-setup ` -- make a sandbox able to hibernate and +# resume. Overlay only: the sealed base is never rebuilt for this, because +# `vm build --force` re-seals a new checksum under every existing sandbox. +# ============================================================================ + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +# shellcheck source=launch.sh +source "$(dirname "${BASH_SOURCE[0]}")/launch.sh" +# shellcheck source=ssh.sh +source "$(dirname "${BASH_SOURCE[0]}")/ssh.sh" + +readonly HIBERNATE_GUEST_SCRIPT="/var/tmp/vmbox-hibernate-setup.sh" +readonly HIBERNATE_X11_PROFILE="/var/tmp/vmbox-x11.sh" + +_hibernate_boot() { + local name="$1" + launch_vm "$name" >/dev/null + vm_wait_ssh "$name" 150 || die "sandbox '$name' did not become reachable" +} + +# Clean poweroff, then wait for qemu to go. Not `vm run`: its exit-code +# recovery would boot the guest again behind our back. +_hibernate_poweroff() { + local name="$1" waited=0 + vm_ssh_exec "$name" 'sudo systemctl poweroff' /dev/null 2>&1 || true + while vm_is_running "$name" && (( waited < 90 )); do + sleep 1; waited=$(( waited + 1 )) + done + vm_is_running "$name" && die "sandbox '$name' did not power off within 90s" + return 0 +} + +cmd_hibernate_setup() { + local name swap_mb=0 + name="$(validate_vm_name "${1:-}")"; shift || true + require_vm "$name" + if [[ "${1:-}" == --swap-mb ]]; then + swap_mb="${2:-}"; [[ "$swap_mb" =~ ^[0-9]+$ ]] || die "--swap-mb needs MiB" + fi + + vm_is_running "$name" || _hibernate_boot "$name" + vm_scp_to "$name" "$VMBOX_GUEST_DIR/hibernate-setup.sh" "$HIBERNATE_GUEST_SCRIPT" || + die "could not copy the setup script in" + vm_scp_to "$name" "$VMBOX_GUEST_DIR/vmbox-x11.sh" "$HIBERNATE_X11_PROFILE" || + die "could not copy vmbox-x11.sh in" + vm_ssh_exec "$name" "sudo bash $HIBERNATE_GUEST_SCRIPT $swap_mb $HIBERNATE_X11_PROFILE" any other SHUTDOWN reason, verbatim + none no stop at all + +Usage: last_event.py +""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Iterable +from typing import Any + + +def _events(lines: Iterable[str]) -> Iterable[dict[str, Any]]: + for raw in lines: + line = raw.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(msg, dict): + yield msg + + +def classify(lines: Iterable[str]) -> str: + """Return the kind of the LAST stop in the log (see module docstring).""" + kind = "none" + suspend_disk = False + for msg in _events(lines): + event = msg.get("event", "") + if event == "GUEST_PANICKED": + kind = "panic" + elif event == "SUSPEND_DISK": + # Only meaningful for the SHUTDOWN that follows it. + suspend_disk = True + elif event == "SHUTDOWN": + data = msg.get("data", {}) + reason = data.get("reason", "") + if not reason: + reason = "guest-shutdown" if data.get("guest") else "host" + kind = ( + "hibernate" if suspend_disk and reason == "guest-shutdown" else reason + ) + suspend_disk = False + return kind + + +def main() -> int: + if len(sys.argv) != 2: + print(__doc__, file=sys.stderr) + return 2 + try: + with open(sys.argv[1], encoding="utf-8") as fh: + print(classify(fh)) + except FileNotFoundError: + print("none") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vmbox/lib/launch.sh b/vmbox/lib/launch.sh index d8c20f1..d8c7196 100644 --- a/vmbox/lib/launch.sh +++ b/vmbox/lib/launch.sh @@ -11,6 +11,8 @@ # shellcheck source=common.sh source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +# shellcheck source=firmware.sh +source "$(dirname "${BASH_SOURCE[0]}")/firmware.sh" readonly VMBOX_MEM="${VMBOX_MEM:-4096}" readonly VMBOX_SMP="${VMBOX_SMP:-4}" @@ -55,7 +57,10 @@ launch_vm() { overlay="$(vm_overlay "$name")" port="$(meta_get "$name" ssh_port)" index="$(meta_get "$name" index)" - rtc="$(meta_get "$name" rtc 2>/dev/null || true)" + # VMBOX_RTC_ONCE is a one-launch override and is never written to meta: + # `vm wake` powers the guest on at the instant its alarm fired, without + # moving the sandbox's own pin. + rtc="${VMBOX_RTC_ONCE:-$(meta_get "$name" rtc 2>/dev/null || true)}" serial_n="$(vm_next_serial "$name")" serial="$(vm_serial "$name" "$serial_n")" qmp="$(vm_qmp_sock "$name")" @@ -111,6 +116,9 @@ launch_vm() { ) [[ -n "$rtc" ]] && args+=(-rtc "base=$rtc") + # One-shot, for `vm wake`: see DOCS-rtc-wake.md ("TCO watchdog"). + [[ -n "${VMBOX_WATCHDOG_ACTION:-}" ]] && args+=(-action "watchdog=$VMBOX_WATCHDOG_ACTION") + firmware_qemu_args "$name" args _launch_qemu "$name" "$serial" "$qmp" args printf '%s' "$serial_n" diff --git a/vmbox/lib/overlay.sh b/vmbox/lib/overlay.sh index f50e78e..995837b 100644 --- a/vmbox/lib/overlay.sh +++ b/vmbox/lib/overlay.sh @@ -9,6 +9,8 @@ # shellcheck source=common.sh source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +# shellcheck source=firmware.sh +source "$(dirname "${BASH_SOURCE[0]}")/firmware.sh" # The base must never be written to: qcow2 overlays record their backing file's # state, so booting the base once silently invalidates EVERY overlay on it. @@ -46,19 +48,22 @@ _overlay_next_index() { } overlay_new() { - local name rtc="" arg + local name rtc="" firmware=bios arg name="$(validate_vm_name "${1:-}")"; shift || true while [[ $# -gt 0 ]]; do arg="$1" case "$arg" in --rtc) rtc="${2:-}"; [[ -n "$rtc" ]] || die "--rtc needs a timestamp"; shift 2 ;; + --uefi) firmware=uefi; shift ;; *) die "unknown option for 'vm new': $arg" ;; esac done vm_exists "$name" && die "sandbox '$name' already exists (reset it with: vm reset $name)" overlay_verify_base + # Fail before anything is created, not halfway through. + if [[ "$firmware" == uefi ]]; then _firmware_file executable >/dev/null || exit 1; fi local dir index dir="$(vm_dir "$name")" @@ -71,9 +76,11 @@ overlay_new() { # Stored, not passed once: every `vm run` is a fresh qemu process, so the # clock must be re-applied on each launch or boot #2 drifts to wall-clock. [[ -n "$rtc" ]] && meta_set "$name" rtc "$rtc" + meta_set "$name" firmware "$firmware" + [[ "$firmware" == uefi ]] && firmware_init_vars "$name" _overlay_create_disk "$name" - ok "sandbox '$name' ready (ssh port $(( VMBOX_SSH_PORT_BASE + index )), peer ${VMBOX_GUEST_SUBNET}.${index})" + ok "sandbox '$name' ready ($firmware) (ssh port $(( VMBOX_SSH_PORT_BASE + index )), peer ${VMBOX_GUEST_SUBNET}.${index})" } _overlay_create_disk() { @@ -106,6 +113,12 @@ overlay_reset() { # Discard everything the guest did: the overlay holds 100% of its writes. _overlay_create_disk "$name" + # The varstore is guest-writable state too (boot entries, systemd's + # HibernateLocation), so pristine means a fresh copy of it as well. + if [[ -f "$(vm_efivars "$name")" ]]; then + rm -f "$(vm_efivars "$name")" + firmware_init_vars "$name" + fi rm -f "$(vm_dir "$name")"/serial.*.log "$(vm_events "$name")" "$(vm_pidfile "$name")" ok "sandbox '$name' reset to pristine" } diff --git a/vmbox/lib/qmp.py b/vmbox/lib/qmp.py index 060fae2..4be38a9 100755 --- a/vmbox/lib/qmp.py +++ b/vmbox/lib/qmp.py @@ -8,6 +8,9 @@ any VNC or SPICE viewer installed on the host. Usage: qmp.py screendump qmp.py status + qmp.py pause-on-shutdown # a guest poweroff/hibernate leaves + # QEMU alive in 'shutdown' (vm wake) + qmp.py quit """ from __future__ import annotations @@ -19,17 +22,17 @@ import sys from typing import Any, TextIO -def _open(path: str) -> tuple[socket.socket, TextIO]: +def open_session(path: str) -> tuple[socket.socket, TextIO]: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.settimeout(20) sock.connect(path) stream = sock.makefile("rw", encoding="utf-8", newline="\n") stream.readline() # greeting - _command(stream, "qmp_capabilities") + command(stream, "qmp_capabilities") return sock, stream -def _command(stream: TextIO, name: str, **args: Any) -> dict[str, Any]: +def command(stream: TextIO, name: str, **args: Any) -> dict[str, Any]: """Send one command and return its reply, skipping any events in between.""" payload: dict[str, Any] = {"execute": name} if args: @@ -55,7 +58,7 @@ def main() -> int: print(f"qmp: no such socket: {path} (is the sandbox running?)", file=sys.stderr) return 1 - sock, stream = _open(path) + sock, stream = open_session(path) try: if action == "screendump": if len(sys.argv) < 4: @@ -63,9 +66,16 @@ def main() -> int: return 2 out = os.path.abspath(sys.argv[3]) # PNG is native to qemu's screendump; no ImageMagick needed. - reply = _command(stream, "screendump", filename=out, format="png") + reply = command(stream, "screendump", filename=out, format="png") elif action == "status": - reply = _command(stream, "query-status") + reply = command(stream, "query-status") + elif action == "pause-on-shutdown": + # Runtime equivalent of -no-shutdown, so launch defaults stay as + # they are: the machine stops, but its CMOS (and the RTC alarm in + # it) stays readable instead of vanishing with the process. + reply = command(stream, "set-action", shutdown="pause") + elif action == "quit": + reply = command(stream, "quit") else: print(f"qmp: unknown action '{action}'", file=sys.stderr) return 2 diff --git a/vmbox/lib/repo.sh b/vmbox/lib/repo.sh index 503c253..627a93b 100644 --- a/vmbox/lib/repo.sh +++ b/vmbox/lib/repo.sh @@ -89,11 +89,14 @@ _repo_clone() { ok "$repo_name cloned at $head (committed state)" } +# Gitignored files are skipped: build output (a Flutter app's build/ and +# .dart_tool/ run to gigabytes) is not "the edits in front of you", and +# pushing it through 9p OOM-killed a 4 GiB-capped harness (2026-10-01). _repo_rsync() { local name="$1" src="$2" repo_name="$3" log "Copying $repo_name into '$name' (working tree, including uncommitted edits)" vm_ssh_exec "$name" \ - "rsync -a --exclude .git $GUEST_MOUNT/$repo_name/ ~/$repo_name/" || + "rsync -a --exclude .git --filter=':- .gitignore' $GUEST_MOUNT/$repo_name/ ~/$repo_name/" || die "rsync failed" ok "$repo_name copied (working-tree state)" } diff --git a/vmbox/lib/rtc_alarm.py b/vmbox/lib/rtc_alarm.py new file mode 100755 index 0000000..1f26748 --- /dev/null +++ b/vmbox/lib/rtc_alarm.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Read a stopped guest's CMOS RTC alarm from the host, over QMP. + +This is the "did the wake instant survive the guest going down?" probe for +`vm wake`. QEMU keeps a powered-off guest's CMOS alive while the machine sits +in the 'shutdown' runstate (see `qmp.py pause-on-shutdown`), so the alarm the +guest armed with `rtcwake -m no` can be read straight out of the emulated +MC146818 -- not from anything the guest reported about itself. + +The alarm registers hold only h:m:s (QEMU's FADT has no day/month alarm: +"alarms up to one day"), so the wake instant is the NEXT time the RTC matches +them -- the same comparison the hardware makes. + +Usage: + rtc_alarm.py read # JSON report; guest must be stopped + rtc_alarm.py now # current RTC as a UTC epoch + rtc_alarm.py decode [] + # offline decode (unit tests); hex ok +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any, TextIO + +from qmp import command, open_session + +REG_B_SET_24H = 0x02 +REG_B_BINARY = 0x04 +REG_B_AIE = 0x20 +PM1_EN_RTC_EN = 0x400 +DONT_CARE = 0xC0 +# ICH9 ACPI PM I/O base as programmed by both SeaBIOS and OVMF on q35. +PM_BASE = int(os.environ.get("VMBOX_PM_BASE", "0x600"), 0) +CMOS_ALARM_REGS = {"sec": 0x01, "min": 0x03, "hour": 0x05, "reg_b": 0x0B} + + +@dataclass(frozen=True) +class AlarmReport: + """Everything `vm wake` needs to decide whether, and when, to power on.""" + + rtc_now: datetime + alarm: datetime | None + aie: bool + rtc_en: bool | None + regs: dict[str, int] + + @property + def armed(self) -> bool: + """AIE on and registers decodable: a real board would wake.""" + return self.aie and self.alarm is not None + + def as_dict(self) -> dict[str, Any]: + """JSON-ready view; epochs are the RTC read as UTC, like rtcwake does.""" + out: dict[str, Any] = { + "rtc_now": _iso(self.rtc_now), + "rtc_now_epoch": int(self.rtc_now.timestamp()), + "aie": self.aie, + "rtc_en": self.rtc_en, + "armed": self.armed, + "regs": {k: f"0x{v:02x}" for k, v in self.regs.items()}, + "alarm": None, + "alarm_epoch": None, + "lead_s": None, + } + if self.alarm is not None: + out["alarm"] = _iso(self.alarm) + out["alarm_epoch"] = int(self.alarm.timestamp()) + out["lead_s"] = int((self.alarm - self.rtc_now).total_seconds()) + return out + + +def _iso(when: datetime) -> str: + # The exact shape QEMU's `-rtc base=` accepts. + return when.strftime("%Y-%m-%dT%H:%M:%S") + + +def decode_field(raw: int, bcd: bool) -> int | None: + """One alarm byte; None when it is a don't-care (matches any value).""" + if raw & DONT_CARE == DONT_CARE: + return None + return (raw >> 4) * 10 + (raw & 0x0F) if bcd else raw + + +def decode_hour(raw: int, bcd: bool, h24: bool) -> int | None: + """Hour alarm byte, honouring 12-hour mode (bit 7 = PM).""" + if raw & DONT_CARE == DONT_CARE: + return None + if h24: + return decode_field(raw, bcd) + value = decode_field(raw & 0x7F, bcd) + if value is None: + return None + return value % 12 + (12 if raw & 0x80 else 0) + + +def next_match( + now: datetime, sec: int | None, minute: int | None, hour: int | None +) -> datetime | None: + """First instant >= now whose h:m:s matches; None if none within a day.""" + start = now.replace(microsecond=0) + for step in range(24 * 3600 + 1): + t = start + timedelta(seconds=step) + if ( + (sec is None or t.second == sec) + and (minute is None or t.minute == minute) + and (hour is None or t.hour == hour) + ): + return t + return None + + +def build_report( + now: datetime, regs: dict[str, int], pm1_en: int | None +) -> AlarmReport: + """Decode raw register values into an AlarmReport.""" + reg_b = regs["reg_b"] + bcd = not reg_b & REG_B_BINARY + h24 = bool(reg_b & REG_B_SET_24H) + alarm = next_match( + now, + decode_field(regs["sec"], bcd), + decode_field(regs["min"], bcd), + decode_hour(regs["hour"], bcd, h24), + ) + rtc_en = None if pm1_en is None else bool(pm1_en & PM1_EN_RTC_EN) + return AlarmReport(now, alarm, bool(reg_b & REG_B_AIE), rtc_en, regs) + + +def _hmp(stream: TextIO, line: str) -> str: + reply = command(stream, "human-monitor-command", **{"command-line": line}) + if "error" in reply: + raise SystemExit(f"rtc_alarm: '{line}' failed: {reply['error']}") + return str(reply.get("return", "")) + + +def _port_in(stream: TextIO, size: str, port: int) -> int: + match = re.search(r"=\s*(0x[0-9a-fA-F]+)", _hmp(stream, f"i /{size} 0x{port:x}")) + if not match: + raise SystemExit(f"rtc_alarm: unreadable I/O port 0x{port:x}") + return int(match.group(1), 16) + + +def _cmos(stream: TextIO, index: int) -> int: + # Index then data. Never index 0x0C: reading register C clears the flags. + _hmp(stream, f"o /b 0x70 0x{index:02x}") + return _port_in(stream, "b", 0x71) + + +def rtc_now(stream: TextIO) -> datetime: + """The guest RTC's current value, read as UTC.""" + reply = command(stream, "qom-get", path="/machine", property="rtc-time") + if "error" in reply: + raise SystemExit(f"rtc_alarm: cannot read rtc-time: {reply['error']}") + tm = reply["return"] + return datetime( + tm["tm_year"] + 1900, + tm["tm_mon"] + 1, + tm["tm_mday"], + tm["tm_hour"], + tm["tm_min"], + tm["tm_sec"], + tzinfo=UTC, + ) + + +def read_live(stream: TextIO) -> AlarmReport: + """Read the alarm out of a STOPPED guest's emulated CMOS.""" + status = command(stream, "query-status").get("return", {}) + if status.get("running"): + # The CMOS index register is shared with the guest; poking it while + # the guest runs could corrupt one of its own RTC accesses. + raise SystemExit("rtc_alarm: guest is running; read only after it stopped") + regs = {name: _cmos(stream, index) for name, index in CMOS_ALARM_REGS.items()} + pm1_en = _port_in(stream, "h", PM_BASE + 2) + return build_report(rtc_now(stream), regs, pm1_en) + + +def _decode_cli(args: list[str]) -> AlarmReport: + now = datetime.fromtimestamp(int(args[0]), tz=UTC) + vals = [int(a, 0) for a in args[1:]] + regs = dict(zip(("sec", "min", "hour", "reg_b"), vals[:4], strict=True)) + return build_report(now, regs, vals[4] if len(vals) > 4 else None) + + +def main() -> int: + argv = sys.argv[1:] + if argv[:1] == ["decode"] and len(argv) in (6, 7): + print(json.dumps(_decode_cli(argv[1:]).as_dict())) + return 0 + if len(argv) != 2 or argv[0] not in ("read", "now"): + print(__doc__, file=sys.stderr) + return 2 + if not os.path.exists(argv[1]): + print(f"rtc_alarm: no such socket: {argv[1]}", file=sys.stderr) + return 1 + sock, stream = open_session(argv[1]) + try: + if argv[0] == "now": + print(int(rtc_now(stream).timestamp())) + else: + print(json.dumps(read_live(stream).as_dict())) + finally: + stream.close() + sock.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vmbox/lib/rtcwake.sh b/vmbox/lib/rtcwake.sh new file mode 100644 index 0000000..9946021 --- /dev/null +++ b/vmbox/lib/rtcwake.sh @@ -0,0 +1,230 @@ +#!/bin/bash +# ============================================================================ +# vmbox: `vm wake` -- emulate "the PC wakes itself on its RTC alarm". +# +# QEMU does not power a guest on from S5, or resume it from S4, when the CMOS +# alarm fires: measured on qemu 11.1.1 (SeaBIOS and OVMF), the alarm flag in +# register C sets and the machine stays in 'shutdown'. Only S3 wakes natively. +# So the firmware's half is emulated here, and everything around it is real: +# +# 1. QEMU is told to PAUSE instead of exiting when the guest goes down, so +# the guest's CMOS outlives the poweroff/hibernate the way it does on a +# real board. +# 2. The armed instant is read from that CMOS over QMP (rtc_alarm.py), not +# from anything the guest said about itself. +# 3. We wait until the guest RTC reaches it (optionally time-accelerated), +# then power the machine on with the RTC at that instant -- a cold boot +# for S5, a resume from the swap image for S4, on identical hardware. +# ============================================================================ + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +# shellcheck source=launch.sh +source "$(dirname "${BASH_SOURCE[0]}")/launch.sh" +# shellcheck source=ssh.sh +source "$(dirname "${BASH_SOURCE[0]}")/ssh.sh" + +readonly WAKE_DOWN_TIMEOUT="${VMBOX_WAKE_DOWN_TIMEOUT:-240}" +readonly WAKE_UP_TIMEOUT="${VMBOX_WAKE_UP_TIMEOUT:-180}" + +# Exit codes, distinct so a test can assert the failure it expects. +readonly WAKE_RC_NOT_ARMED=9 WAKE_RC_NEVER_DOWN=10 WAKE_RC_NOT_BACK=11 +readonly WAKE_RC_TOO_LONG=12 WAKE_RC_WRONG_STOP=13 WAKE_RC_CLOCK=14 + +_wake_qmp() { python3 "$VMBOX_LIB_DIR/qmp.py" "$(vm_qmp_ctl "$1")" "${@:2}"; } +_wake_alarm() { python3 "$VMBOX_LIB_DIR/rtc_alarm.py" "$1" "$(vm_qmp_ctl "$2")"; } +_wake_kv() { printf 'RTCWAKE %s=%s\n' "$1" "$2"; } +_wake_utc() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%S; } + +# "mem smp firmware" of the RUNNING qemu. A resume needs the hardware it +# hibernated on -- a different RAM size makes the kernel discard the image -- +# so the relaunch replays what was actually used, not today's defaults. +_wake_hw_of_running() { + local pid i mem="" smp="" fw=bios + local -a argv=() + pid="$(cat "$(vm_pidfile "$1")")" + mapfile -d '' -t argv < "/proc/$pid/cmdline" + for (( i = 0; i < ${#argv[@]}; i++ )); do + case "${argv[i]}" in + -m) mem="${argv[i + 1]}" ;; + -smp) smp="${argv[i + 1]}" ;; + if=pflash*) fw=uefi ;; + esac + done + printf '%s %s %s' "$mem" "$smp" "$fw" +} + +# Poll the event log (past line $2) until the guest stops or panics. +_wake_wait_down() { + local name="$1" from="$2" events now deadline + events="$(vm_events "$name")" + printf -v now '%(%s)T' -1 + deadline=$(( now + WAKE_DOWN_TIMEOUT )) + while (( now < deadline )); do + tail -n +"$(( from + 1 ))" "$events" 2>/dev/null | + grep -qE '"event": "(SHUTDOWN|GUEST_PANICKED)"' && return 0 + vm_is_running "$name" || return 1 + sleep 1 + printf -v now '%(%s)T' -1 + done + return 1 +} + +_wake_quit() { + local name="$1" pid waited=0 + pid="$(cat "$(vm_pidfile "$name")" 2>/dev/null || true)" + _wake_qmp "$name" quit >/dev/null 2>&1 || true + while [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null && (( waited < 30 )); do + sleep 1; waited=$(( waited + 1 )) + done + vm_is_running "$name" && die "qemu for '$name' did not exit after quit" + return 0 +} + +cmd_wake() { + local name accel=1 max_wait=900 + name="$(validate_vm_name "${1:-}")"; shift || true + require_vm "$name" + while [[ $# -gt 0 ]]; do + case "$1" in + --accel) accel="${2:-}"; shift 2 ;; + --max-wait) max_wait="${2:-}"; shift 2 ;; + --) shift; break ;; + *) break ;; + esac + done + [[ "$accel" =~ ^[1-9][0-9]*$ ]] || die "--accel needs a positive integer" + [[ "$max_wait" =~ ^[0-9]+$ ]] || die "--max-wait needs seconds" + [[ $# -gt 0 ]] || die "usage: vm wake [--accel N] [--max-wait S] [--] " + command -v jq >/dev/null || die "vm wake needs jq -- run: $VMBOX_ROOT/install.sh" + + if ! vm_is_running "$name"; then + launch_vm "$name" >/dev/null + vm_wait_ssh "$name" 150 || die "sandbox '$name' did not become reachable" + fi + _wake_qmp "$name" pause-on-shutdown >/dev/null || die "could not set shutdown=pause over QMP" + + local events hw before=0 ssh_pid + events="$(vm_events "$name")" + [[ -f "$events" ]] && before="$(wc -l < "$events")" + hw="$(_wake_hw_of_running "$name")" + + log "Running in '$name' (machine will pause, not exit, when it goes down): $*" + vm_ssh_exec "$name" "$*" /dev/null || true + warn "the guest did not go down within ${WAKE_DOWN_TIMEOUT}s" + _wake_kv result never-down + return "$WAKE_RC_NEVER_DOWN" + fi + kill "$ssh_pid" 2>/dev/null || true + wait "$ssh_pid" 2>/dev/null || true + + local scoped="$events.wake" kind + tail -n +"$(( before + 1 ))" "$events" > "$scoped" + kind="$(python3 "$VMBOX_LIB_DIR/last_event.py" "$scoped")" + rm -f "$scoped" + case "$kind" in + guest-shutdown) kind=poweroff ;; + hibernate) ;; + *) warn "the guest stopped by '$kind', not a poweroff or hibernate" + # Do not leave it paused in 'shutdown': it would list as running + # while the guest is down, and the next `vm run` would stall. + _wake_quit "$name" + _wake_kv result "wrong-stop:$kind" + return "$WAKE_RC_WRONG_STOP" ;; + esac + _wake_kv down "$kind" + _wake_after_down "$name" "$accel" "$max_wait" "$hw" +} + +_wake_after_down() { + local name="$1" accel="$2" max_wait="$3" hw="$4" report + report="$(_wake_alarm read "$name")" || die "could not read the CMOS alarm over QMP" + + local armed alarm_epoch down_epoch lead aie rtc_en regs + armed="$(jq -r '.armed' <<< "$report")" + aie="$(jq -r '.aie' <<< "$report")" + rtc_en="$(jq -r '.rtc_en' <<< "$report")" + regs="$(jq -c '.regs' <<< "$report")" + down_epoch="$(jq -r '.rtc_now_epoch' <<< "$report")" + _wake_kv rtc_at_down "$(_wake_utc "$down_epoch")" + _wake_kv cmos_regs "$regs" + _wake_kv aie "$aie" + _wake_kv rtc_en "$rtc_en" + if [[ "$armed" != true ]]; then + warn "NO WAKE ARMED: alarm interrupt disabled in CMOS -- the machine would stay off" + _wake_quit "$name" + _wake_kv result not-armed + return "$WAKE_RC_NOT_ARMED" + fi + alarm_epoch="$(jq -r '.alarm_epoch' <<< "$report")" + lead="$(jq -r '.lead_s' <<< "$report")" + _wake_kv armed_epoch "$alarm_epoch" + _wake_kv armed_utc "$(_wake_utc "$alarm_epoch")" + _wake_kv lead_guest_s "$lead" + [[ "$rtc_en" == false ]] && + warn "PM1_EN.RTC_EN is clear: QEMU does not care, but a real chipset may not wake" + + local real_wait=$(( (lead + accel - 1) / accel )) + if (( real_wait > max_wait )); then + warn "alarm is ${lead}s of guest time away (${real_wait}s real at x$accel) > --max-wait $max_wait" + _wake_quit "$name" + _wake_kv result too-far + return "$WAKE_RC_TOO_LONG" + fi + log "Machine is off; alarm in ${lead}s guest time -> waiting ${real_wait}s real (x$accel)" + local t0 t1 + printf -v t0 '%(%s)T' -1 + sleep "$real_wait" + printf -v t1 '%(%s)T' -1 + _wake_kv accel "$accel" + _wake_kv waited_real_s "$(( t1 - t0 ))" + + # The firmware wakes the instant the RTC matches; with acceleration the + # real RTC is still short of that, so power on AT the alarm instant. + local now_epoch base + now_epoch="$(_wake_alarm now "$name")" + base=$(( now_epoch > alarm_epoch ? now_epoch : alarm_epoch )) + _wake_kv poweron_rtc "$(_wake_utc "$base")" + _wake_quit "$name" + _wake_power_on "$name" "$hw" "$base" "$alarm_epoch" +} + +_wake_power_on() { + local name="$1" hw="$2" base="$3" alarm_epoch="$4" mem smp fw t0 t1 guest_now + read -r mem smp fw <<< "$hw" + log "Powering '$name' on (RTC $(_wake_utc "$base"), ${mem}M x${smp}, $fw)" + printf -v t0 '%(%s)T' -1 + # `env`, not a prefix assignment: VMBOX_MEM/SMP are readonly in this + # shell (launch.sh), and the relaunch must not inherit this shell's values. + # watchdog=none: QEMU reset every resumed guest ~20 s in. TCO_TMR reads + # 0x32 (30 s) after a cold boot but 0x04 (2.4 s, QEMU's default) after a + # resume, and the restored driver keeps pinging every 15 s (measured). + # An emulator artefact, not the flow under test; the WATCHDOG events + # still land in events.jsonl. + if ! env VMBOX_MEM="$mem" VMBOX_SMP="$smp" VMBOX_FIRMWARE="$fw" \ + VMBOX_RTC_ONCE="$(_wake_utc "$base")" VMBOX_START_TIMEOUT="$WAKE_UP_TIMEOUT" \ + VMBOX_WATCHDOG_ACTION=none "$VMBOX_ROOT/bin/vm" start "$name" >/dev/null; then + warn "the guest did not come back within ${WAKE_UP_TIMEOUT}s" + _wake_kv result not-back + return "$WAKE_RC_NOT_BACK" + fi + printf -v t1 '%(%s)T' -1 + guest_now="$(vm_ssh_exec "$name" 'date +%s' | tr -dc '0-9')" + _wake_kv back_after_real_s "$(( t1 - t0 ))" + _wake_kv guest_clock_at_ssh "$(_wake_utc "$guest_now")" + _wake_kv up_after_alarm_guest_s "$(( guest_now - alarm_epoch ))" + # Powered on AT the alarm, so a guest clock behind it means the guest did + # not take its time from the RTC -- a resume under kvmclock does exactly + # that, and every time-gated unit would then decide on the wrong time. + if (( guest_now < alarm_epoch )); then + warn "guest clock is $(( alarm_epoch - guest_now ))s BEHIND its own alarm: it did not read" + warn "the RTC (kvmclock?). Run: vm hibernate-setup $name (adds no-kvmclock)" + _wake_kv result clock-not-from-rtc + return "$WAKE_RC_CLOCK" + fi + _wake_kv result woke + ok "WAKE: guest is back $(( guest_now - alarm_epoch ))s (guest clock) after its alarm" +} diff --git a/vmbox/lib/run.sh b/vmbox/lib/run.sh index 28628a8..fc7919c 100644 --- a/vmbox/lib/run.sh +++ b/vmbox/lib/run.sh @@ -43,7 +43,11 @@ run_in_vm() { local name name="$(validate_vm_name "${1:-}")"; shift || true require_vm "$name" - [[ $# -gt 0 ]] || die "usage: vm run " + # Per-launch firmware override; only matters when this run boots the VM. + while [[ "${1:-}" == --uefi || "${1:-}" == --bios ]]; do + export VMBOX_FIRMWARE="${1#--}"; shift + done + [[ $# -gt 0 ]] || die "usage: vm run [--uefi|--bios] " local serial_n serial events if vm_is_running "$name"; then @@ -174,6 +178,9 @@ _run_finish() { verdict_report "$name" "$serial" "$scoped" || vrc=$? rm -f "$scoped" + # A hibernated guest is left alone: booting it to read the exit code + # would resume (and so consume) the very image the caller wants to test. + (( vrc == 8 )) && return "$vrc" # If the guest stopped, recover the real exit code from the overlay. if ! vm_is_running "$name"; then local rc diff --git a/vmbox/lib/verdict.sh b/vmbox/lib/verdict.sh index 17a56ed..c380a15 100644 --- a/vmbox/lib/verdict.sh +++ b/vmbox/lib/verdict.sh @@ -15,33 +15,11 @@ source "$(dirname "${BASH_SOURCE[0]}")/common.sh" readonly POWEROFF_MARKER='reboot: Power down' # Read the last SHUTDOWN/panic event out of events.jsonl. -# Echoes one of: guest-shutdown | guest-reset | host-* | panic | none +# Echoes one of: guest-shutdown | hibernate | guest-reset | host-* | panic | none verdict_last_event() { local events="$1" [[ -s "$events" ]] || { printf 'none'; return 0; } - python3 - "$events" <<'PY' -import json, sys - -kind = "none" -with open(sys.argv[1], encoding="utf-8") as fh: - for line in fh: - line = line.strip() - if not line: - continue - try: - msg = json.loads(line) - except json.JSONDecodeError: - continue - event = msg.get("event", "") - if event == "GUEST_PANICKED": - kind = "panic" - elif event == "SHUTDOWN": - data = msg.get("data", {}) - reason = data.get("reason", "") - # `guest` distinguishes a guest-initiated stop from one we caused. - kind = reason if reason else ("guest-shutdown" if data.get("guest") else "host") -print(kind) -PY + python3 "$VMBOX_LIB_DIR/last_event.py" "$events" } # Did the guest reach the end of its shutdown sequence, or die partway? @@ -67,6 +45,7 @@ verdict_is_alive() { # Print the human-readable verdict and return a distinct exit code per outcome: # 0 clean poweroff 3 dirty 4 rebooted 5 panicked 6 hung 7 still running +# 8 hibernated verdict_report() { local name="$1" serial="$2" events="$3" kind kind="$(verdict_last_event "$events")" @@ -81,6 +60,12 @@ verdict_report() { warn " '$POWEROFF_MARKER'; the sequence was cut short (see $serial)" return 3 ;; + hibernate) + # Not a failure and not a poweroff: S4 leaves no 'Power down' line, + # which used to be misreported as a DIRTY shutdown. + ok "VERDICT: hibernated -- the guest entered ACPI S4 (QMP SUSPEND_DISK)" + return 8 + ;; guest-reset) warn "VERDICT: REBOOTED, did not power off -- the guest reset instead of halting" return 4 diff --git a/vmbox/tests/rtcwake_e2e.sh b/vmbox/tests/rtcwake_e2e.sh new file mode 100755 index 0000000..1d3afca --- /dev/null +++ b/vmbox/tests/rtcwake_e2e.sh @@ -0,0 +1,155 @@ +#!/bin/bash +# ============================================================================ +# vmbox: end-to-end proof of `vm wake` (RTC wake emulation), on a real guest. +# +# A. poweroff: arm `rtcwake -m no -s 120`, power off; the harness must find +# the alarm in the stopped guest's CMOS, power it on at that instant, +# and the guest's boot-time unit must run AFTER the armed instant. +# B. hibernate: same, but `systemctl hibernate`; the guest must RESUME +# (same boot_id, a pre-hibernate process still alive, the post-resume +# hook ran after the armed instant) and survive a watchdog window. +# C. negative control: arm, then `rtcwake -m disable`, power off; the +# harness must refuse to power it on (exit 9). A harness that has only +# ever woken a machine is untested. +# +# Usage: tests/rtcwake_e2e.sh [--uefi] [--keep] +# Env: VM (sandbox, default rtcwake), ACCEL_POWEROFF (default 1: a real +# two-minute wait), ACCEL_HIBERNATE (default 10), VMBOX_MEM (2048). +# Run it under ~/.claude/scripts/capped.sh (CAP_MEM=4G CAP_CPU_PCT=20). +# ============================================================================ + +set -euo pipefail + +VMBOX_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +readonly VMBOX_ROOT +readonly VM_CLI="$VMBOX_ROOT/bin/vm" +readonly VM="${VM:-rtcwake}" +readonly ACCEL_POWEROFF="${ACCEL_POWEROFF:-1}" +readonly ACCEL_HIBERNATE="${ACCEL_HIBERNATE:-10}" +# Small enough for capped.sh's 4G ceiling, and swap is sized from it. +export VMBOX_MEM="${VMBOX_MEM:-2048}" +readonly PROBE=/var/tmp/rtcwake_guest_probe.sh +readonly ARM='sudo rtcwake -m no -s 120 >/dev/null && sudo cp /sys/class/rtc/rtc0/wakealarm /var/lib/vmbox-wake/armed' + +uefi=() +keep=0 +pass=0 +fail=0 + +step() { printf '\n\033[0;34m=== %s\033[0m\n' "$*"; } +check() { + local label="$1"; shift + if "$@"; then + printf '\033[0;32m PASS\033[0m %s\n' "$label"; pass=$(( pass + 1 )) + else + printf '\033[0;31m FAIL\033[0m %s\n' "$label"; fail=$(( fail + 1 )) + fi +} +guest() { "$VM_CLI" ssh "$VM" "$@" /dev/null; } +# Not via `vm ssh`: it boots a stopped sandbox, which once turned a guest that +# had been reset into a "still up" PASS. +# Capture first: `list | grep -q` under pipefail fails on the SIGPIPE grep -q +# causes by exiting early, i.e. exactly when the VM IS running. +running() { local l; l="$("$VM_CLI" list)"; grep -qE "^$VM +running" <<< "$l"; } +stopped() { ! running; } +readonly EVENTS="$HOME/.local/share/vmbox/vms/$VM/events.jsonl" +kv() { sed -n "s/^RTCWAKE $1=//p" <<< "$2" | tail -1; } +probe_line() { guest "grep '^$1 ' /var/lib/vmbox-wake/log | tail -1"; } + +# vm wake, keeping both its output and its exit code. +wake() { + local out rc=0 + out="$("$VM_CLI" wake "$VM" "$@" 2>&1)" || rc=$? + printf '%s\n' "$out" | grep -E '^RTCWAKE|WAKE:|NO WAKE' >&2 + printf '%s\nRTCWAKE exit=%s\n' "$out" "$rc" +} + +setup() { + step "Setup: fresh sandbox '$VM' ${uefi[*]:-(bios)}, VMBOX_MEM=$VMBOX_MEM" + "$VM_CLI" rm "$VM" >/dev/null 2>&1 || true + "$VM_CLI" new "$VM" "${uefi[@]}" + "$VM_CLI" hibernate-setup "$VM" + "$VM_CLI" scp "$VM" "$VMBOX_ROOT/tests/rtcwake_guest_probe.sh" "$PROBE" >/dev/null + guest "sudo bash $PROBE install && sudo install -d /var/lib/vmbox-wake" >/dev/null +} + +case_poweroff() { + step "A. poweroff -> harness powers on at the armed instant (x$ACCEL_POWEROFF)" + local before out armed boot_epoch boot_id + before="$(guest 'cat /proc/sys/kernel/random/boot_id')" + out="$(wake --accel "$ACCEL_POWEROFF" -- "$ARM && sudo systemctl poweroff")" + armed="$(kv armed_epoch "$out")" + check "harness exit 0, result=woke" test "$(kv exit "$out")/$(kv result "$out")" = 0/woke + check "stop classified as poweroff" test "$(kv down "$out")" = poweroff + check "CMOS alarm == guest wakealarm ($armed)" \ + test "$armed" = "$(guest 'cat /var/lib/vmbox-wake/armed')" + read -r _ boot_epoch boot_id <<< "$(probe_line boot)" + check "boot unit ran at $boot_epoch >= armed $armed" test "${boot_epoch:-0}" -ge "${armed:-1}" + check "it was a fresh boot (boot_id changed)" test "${boot_id:-}" != "$before" +} + +case_hibernate() { + step "B. hibernate -> harness resumes at the armed instant (x$ACCEL_HIBERNATE)" + local before pid out armed res_epoch res_id + before="$(guest 'cat /proc/sys/kernel/random/boot_id')" + guest "setsid -f sh -c 'echo \$\$ > /var/tmp/sleeper.pid; exec sleep 31536000' >/dev/null 2>&1 = armed $armed" test "${res_epoch:-0}" -ge "${armed:-1}" + check "resume hook ran in the same boot" test "${res_id:-}" = "$before" + # Unmasked, the ICH9 TCO watchdog reset every resumed guest ~20 s in (see + # DOCS-rtc-wake.md). A resume that dies a minute in is not a resume. + local launches + launches="$(grep -c _RECORDER_READY "$EVENTS")" + sleep 45 + check "still running 45 s after resume" running + check "never relaunched (still $launches launches)" \ + test "$(grep -c _RECORDER_READY "$EVENTS")" = "$launches" + local now_id + now_id="$(running && guest 'cat /proc/sys/kernel/random/boot_id' || echo not-running)" + check "...and still the same boot ($now_id)" test "$now_id" = "$before" + # The X session is dead after a resume (virtio-gpu, see DOCS); `vm run` + # must still return rather than hang on it. + check "vm run still returns after resume" timeout 60 "$VM_CLI" run "$VM" true + printf ' info: WATCHDOG expiries in the log (ignored by vm wake): %s\n' \ + "$(grep -c '"WATCHDOG"' "$EVENTS" || true)" +} + +case_not_armed() { + step "C. negative control: alarm disabled before poweroff -> must stay off" + local out + out="$(wake --accel 10 -- "$ARM && sudo rtcwake -m disable >/dev/null && sudo systemctl poweroff")" + check "harness exit 9, result=not-armed" test "$(kv exit "$out")/$(kv result "$out")" = 9/not-armed + check "AIE reported off" test "$(kv aie "$out")" = false + check "machine left powered off" stopped +} + +main() { + while [[ $# -gt 0 ]]; do + case "$1" in + --uefi) uefi=(--uefi); shift ;; + --keep) keep=1; shift ;; + *) echo "usage: $0 [--uefi] [--keep]" >&2; exit 2 ;; + esac + done + setup + case_poweroff + case_hibernate + case_not_armed + (( keep )) || "$VM_CLI" rm "$VM" >/dev/null + printf '\n\033[0;34m=== RESULT: %d passed, %d failed\033[0m\n' "$pass" "$fail" + (( fail == 0 )) +} + +main "$@" diff --git a/vmbox/tests/rtcwake_guest_probe.sh b/vmbox/tests/rtcwake_guest_probe.sh new file mode 100755 index 0000000..a3643b6 --- /dev/null +++ b/vmbox/tests/rtcwake_guest_probe.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# ============================================================================ +# Runs INSIDE the guest (as root) for tests/rtcwake_e2e.sh. +# +# rtcwake_guest_probe.sh install install a boot-time unit and a resume hook +# rtcwake_guest_probe.sh append " " to the log +# +# These stand in for the units a real wake flow depends on (wake-alarm's +# on-boot timer, a post-resume hook): the test asserts they RAN, and when, +# relative to the armed alarm -- not merely that the guest answered ssh. +# ============================================================================ + +set -euo pipefail + +readonly LOG_DIR=/var/lib/vmbox-wake +readonly LOG="$LOG_DIR/log" +readonly SELF=/usr/local/sbin/vmbox-wake-probe + +record() { + local boot_id epoch + read -r boot_id < /proc/sys/kernel/random/boot_id + printf -v epoch '%(%s)T' -1 + install -d -m 755 "$LOG_DIR" + printf '%s %s %s\n' "$1" "$epoch" "$boot_id" >> "$LOG" +} + +install_probe() { + install -m 755 "$0" "$SELF" + cat > /etc/systemd/system/vmbox-wake-boot.service < /usr/lib/systemd/system-sleep/vmbox-wake-probe <" >&2; exit 2 ;; + *) record "$1" ;; +esac diff --git a/vmbox/tests/test_rtcwake.bats b/vmbox/tests/test_rtcwake.bats new file mode 100644 index 0000000..072bf50 --- /dev/null +++ b/vmbox/tests/test_rtcwake.bats @@ -0,0 +1,189 @@ +#!/usr/bin/env bats +# ============================================================================ +# vmbox: host-side unit tests for UEFI mode and RTC wake emulation. +# +# No guest is booted: the CMOS decoder is fed register values (the BCD case +# is the exact set read from a real guest on 2026-10-01), and firmware +# selection runs against fake OVMF files. The booting proof is +# tests/rtcwake_e2e.sh. VMBOX_HOME is a per-test temp dir, as in +# test_vmbox.bats -- nothing here may touch ~/.local/share/vmbox. +# ============================================================================ + +setup() { + VMBOX_TEST_HOME="$(mktemp -d)" + export VMBOX_HOME="$VMBOX_TEST_HOME" + REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd -P)" + export REPO_ROOT + source "$REPO_ROOT/lib/common.sh" + source "$REPO_ROOT/lib/verdict.sh" + source "$REPO_ROOT/lib/firmware.sh" + install -d "$VMBOX_VMS_DIR" +} + +teardown() { + [[ -n "${VMBOX_TEST_HOME:-}" && -d "$VMBOX_TEST_HOME" ]] && rm -rf "$VMBOX_TEST_HOME" +} + +decode() { python3 "$REPO_ROOT/lib/rtc_alarm.py" decode "$@"; } +field() { jq -r ".$1" <<< "$output"; } + +# 2026-10-01T09:01:53Z, the RTC value read from the live guest. +NOW=1790845313 + +# --------------------------------------------------------------------------- +# CMOS alarm decoding +# --------------------------------------------------------------------------- + +@test "decode: BCD 24h registers from a real guest give rtcwake's exact epoch" { + run decode "$NOW" 0x33 0x02 0x09 0x22 0x0520 + [ "$status" -eq 0 ] + [ "$(field alarm_epoch)" = 1790845353 ] # what `rtcwake -s 60` printed + [ "$(field armed)" = true ] + [ "$(field rtc_en)" = true ] + [ "$(field lead_s)" = 40 ] +} + +@test "decode: alarm interrupt off means NOT armed, even with valid registers" { + run decode "$NOW" 0x33 0x02 0x09 0x02 + [ "$(field armed)" = false ] + [ "$(field aie)" = false ] + [ "$(field rtc_en)" = null ] +} + +@test "decode: binary mode (REG_B DM bit) is not read as BCD" { + run decode "$NOW" 51 2 9 0x26 + [ "$(field alarm)" = 2026-10-01T09:02:51 ] +} + +@test "decode: 12-hour mode maps PM, 12 AM and 12 PM correctly" { + run decode "$NOW" 0x00 0x00 0x89 0x20 + [ "$(field alarm)" = 2026-10-01T21:00:00 ] + run decode "$NOW" 0x00 0x00 0x12 0x20 + [ "$(field alarm)" = 2026-10-02T00:00:00 ] + run decode "$NOW" 0x00 0x00 0x92 0x20 + [ "$(field alarm)" = 2026-10-01T12:00:00 ] +} + +@test "decode: an alarm just past midnight wraps to the next day" { + run decode 1790899190 0x10 0x00 0x00 0x22 # now 23:59:50 + [ "$(field alarm)" = 2026-10-02T00:00:10 ] + [ "$(field lead_s)" = 20 ] +} + +@test "decode: an alarm already in the past is ~24h away, like the hardware" { + run decode "$NOW" 0x00 0x00 0x09 0x22 # 09:00:00, now 09:01:53 + [ "$(field alarm)" = 2026-10-02T09:00:00 ] + [ "$(field lead_s)" -gt 86000 ] +} + +@test "decode: don't-care bytes (0xC0+) match any value" { + run decode "$NOW" 0xff 0xff 0xff 0x22 + [ "$(field lead_s)" = 0 ] +} + +@test "decode: impossible register values are not armed" { + run decode "$NOW" 0x75 0x02 0x09 0x22 # second 75 + [ "$(field alarm)" = null ] + [ "$(field armed)" = false ] +} + +@test "rtc_alarm: refuses unknown actions" { + run python3 "$REPO_ROOT/lib/rtc_alarm.py" poke /nonexistent + [ "$status" -eq 2 ] +} + +# --------------------------------------------------------------------------- +# hibernate classification +# --------------------------------------------------------------------------- + +@test "last_event: SUSPEND_DISK then SHUTDOWN is a hibernate" { + printf '%s\n' '{"event":"SUSPEND_DISK"}' \ + '{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-shutdown"}}' > "$VMBOX_HOME/e" + run verdict_last_event "$VMBOX_HOME/e" + [ "$output" = hibernate ] +} + +@test "last_event: a later plain poweroff is not a hibernate" { + printf '%s\n' '{"event":"SUSPEND_DISK"}' \ + '{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-shutdown"}}' \ + '{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-shutdown"}}' > "$VMBOX_HOME/e" + run verdict_last_event "$VMBOX_HOME/e" + [ "$output" = guest-shutdown ] +} + +@test "verdict: a hibernate is exit 8, not a DIRTY shutdown" { + install -d "$(vm_dir v)" + printf '%s\n' '{"event":"SUSPEND_DISK"}' \ + '{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-shutdown"}}' > "$VMBOX_HOME/e" + : > "$VMBOX_HOME/serial.log" + run verdict_report v "$VMBOX_HOME/serial.log" "$VMBOX_HOME/e" + [ "$status" -eq 8 ] + [[ "$output" == *hibernated* ]] + [[ "$output" != *DIRTY* ]] +} + +# --------------------------------------------------------------------------- +# firmware selection +# --------------------------------------------------------------------------- + +_fake_ovmf() { + printf 'code' > "$VMBOX_HOME/CODE.fd" + printf 'vars-template' > "$VMBOX_HOME/VARS.fd" + export VMBOX_OVMF_CODE="$VMBOX_HOME/CODE.fd" VMBOX_OVMF_VARS="$VMBOX_HOME/VARS.fd" +} + +@test "firmware: default is bios, and bios adds no qemu args" { + install -d "$(vm_dir v)" + [ "$(firmware_of v)" = bios ] + local -a args=() + firmware_qemu_args v args + [ "${#args[@]}" -eq 0 ] +} + +@test "firmware: meta uefi wins over the default, env wins over meta" { + install -d "$(vm_dir v)" + meta_set v firmware uefi + [ "$(firmware_of v)" = uefi ] + VMBOX_FIRMWARE=bios run firmware_of v + [ "$output" = bios ] +} + +@test "firmware: an unknown firmware name is refused" { + install -d "$(vm_dir v)" + VMBOX_FIRMWARE=coreboot run firmware_of v + [ "$status" -ne 0 ] +} + +@test "firmware: uefi adds read-only code + a per-VM varstore copied from the template" { + _fake_ovmf + install -d "$(vm_dir v)" + meta_set v firmware uefi + local -a args=() + firmware_qemu_args v args + [ "${#args[@]}" -eq 4 ] + [[ "${args[1]}" == *"unit=0,readonly=on,file=$VMBOX_HOME/CODE.fd" ]] + [[ "${args[3]}" == *"unit=1,file=$(vm_efivars v)" ]] + [ "$(cat "$(vm_efivars v)")" = vars-template ] +} + +@test "firmware: missing OVMF fails closed instead of booting as bios" { + install -d "$(vm_dir v)" + meta_set v firmware uefi + VMBOX_OVMF_CODE="$VMBOX_HOME/nope.fd" run firmware_qemu_args v args + [ "$status" -ne 0 ] + [[ "$output" == *"not readable"* ]] +} + +# --------------------------------------------------------------------------- +# vm wake argument handling (no guest needed) +# --------------------------------------------------------------------------- + +@test "vm wake: unknown sandbox and bad --accel are refused before anything runs" { + run "$REPO_ROOT/bin/vm" wake nosuch -- true + [ "$status" -ne 0 ] + [[ "$output" == *"no such vm"* ]] + install -d "$(vm_dir v)" + run "$REPO_ROOT/bin/vm" wake v --accel 0 -- true + [ "$status" -ne 0 ] + [[ "$output" == *"positive integer"* ]] +}