vmbox: UEFI sandboxes and 'vm wake' RTC-wake emulation for poweroff/hibernate; worktree copies skip gitignored files
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-01 12:29:54 +02:00
parent aeac9caaa1
commit 694c013b6b
21 changed files with 1495 additions and 48 deletions

View File

@ -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/

178
vmbox/DOCS-rtc-wake.md Normal file
View File

@ -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 <epoch>` +
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=<alarm instant>` (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/<pid>/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 <name> --uefi` stores `firmware=uefi` in meta and copies
`OVMF_VARS.4m.fd` to the sandbox's `efivars.fd`; `vm run <name> --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 <name> '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".

View File

@ -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 <name> [--rtc <ts>]` | Create a sandbox |
| `vm run <name> <cmd...>` | Run a command, print the verdict |
| `vm new <name> [--rtc <ts>] [--uefi]` | Create a sandbox (`--uefi`: OVMF + its own varstore) |
| `vm run <name> [--uefi\|--bios] <cmd...>` | Run a command, print the verdict (hibernate = exit 8) |
| `vm start <name>` | Boot and wait for ssh, nothing else |
| `vm wake <name> [--accel N] -- <cmd...>` | Run a command that arms the RTC and powers off / hibernates; power the guest on when its CMOS alarm fires |
| `vm hibernate-setup <name>` | Swap + `resume=` so `systemctl hibernate` works and resumes |
| `vm ssh <name>` | Interactive shell (starts the VM if needed) |
| `vm screenshot <name> [out.png]` | Capture the screen (locker/X11 tests) — verified: real 1280x800 PNG of the guest's i3 session, no viewer installed |
| `vm lightdm <name>` | 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

View File

@ -19,8 +19,17 @@ usage() {
Usage: vm <command> [args]
build Build the golden base image (once, ~10 min)
new <name> [--rtc <ts>] Create a sandbox (instant; thin overlay)
run <name> <command...> Run a command in the sandbox, report the verdict
new <name> [--rtc <ts>] [--uefi]
Create a sandbox (instant; thin overlay)
run <name> [--uefi|--bios] <command...>
Run a command in the sandbox, report the verdict
start <name> Boot the sandbox and wait until ssh answers
wake <name> [--accel N] [--max-wait S] [--] <command...>
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 <name> [--swap-mb N]
Swap + resume= so the guest can hibernate/resume
ssh <name> [args...] Interactive shell in the sandbox
scp <name> <src> <dest> Copy a host file into the sandbox
share <host-path> Expose a host dir to sandboxes (read-only bind)
@ -40,6 +49,8 @@ Usage: vm <command> [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 <name>)"; 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 "$@" ;;

View File

@ -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 "$@"

View File

@ -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

View File

@ -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

93
vmbox/lib/firmware.sh Normal file
View File

@ -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 <name> --uefi`, stored in meta) or per launch
# (`VMBOX_FIRMWARE=uefi|bios`, which `vm run <name> --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"
)
}

67
vmbox/lib/hibernate.sh Normal file
View File

@ -0,0 +1,67 @@
#!/bin/bash
# ============================================================================
# vmbox: `vm hibernate-setup <name>` -- 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 >/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" </dev/null ||
die "hibernate setup failed in the guest"
# The cmdline only takes effect on the next boot.
log "Rebooting '$name' so the new kernel cmdline applies"
_hibernate_poweroff "$name"
_hibernate_boot "$name"
# Prove it rather than assume it: the running kernel carries the resume
# args (and no-kvmclock, so a resume reads the RTC), and logind (what
# `systemctl hibernate` asks) says yes.
vm_ssh_exec "$name" 'grep -q "resume_offset=.*no-kvmclock" /proc/cmdline' </dev/null ||
die "the guest booted without resume_offset=/no-kvmclock on its cmdline"
local can
# sudo: polkit denies CanHibernate to a non-seat ssh session.
can="$(vm_ssh_exec "$name" 'sudo busctl call org.freedesktop.login1 /org/freedesktop/login1 org.freedesktop.login1.Manager CanHibernate' </dev/null)"
[[ "$can" == 's "yes"' ]] || die "logind CanHibernate says: ${can:-nothing}"
ok "'$name' can hibernate and resume (logind CanHibernate=yes)"
}

74
vmbox/lib/last_event.py Executable file
View File

@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Classify the last stop recorded in a vmbox QMP event log (events.jsonl).
Prints exactly one of:
guest-shutdown the guest powered itself off (ACPI S5)
hibernate the guest hibernated: QEMU emits SUSPEND_DISK right before
the SHUTDOWN when the guest enters ACPI S4
guest-reset the guest rebooted (-no-reboot turns that into a SHUTDOWN)
panic GUEST_PANICKED (pvpanic)
host a SHUTDOWN the guest did not initiate
<reason> any other SHUTDOWN reason, verbatim
none no stop at all
Usage: last_event.py <events.jsonl>
"""
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())

View File

@ -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"

View File

@ -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"
}

View File

@ -8,6 +8,9 @@ any VNC or SPICE viewer installed on the host.
Usage:
qmp.py <qmp.sock> screendump <out.png>
qmp.py <qmp.sock> status
qmp.py <qmp.sock> pause-on-shutdown # a guest poweroff/hibernate leaves
# QEMU alive in 'shutdown' (vm wake)
qmp.py <qmp.sock> 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

View File

@ -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)"
}

215
vmbox/lib/rtc_alarm.py Executable file
View File

@ -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 <qmp.sock.ctl> # JSON report; guest must be stopped
rtc_alarm.py now <qmp.sock.ctl> # current RTC as a UTC epoch
rtc_alarm.py decode <rtc_epoch> <sec> <min> <hour> <reg_b> [<pm1_en>]
# 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())

230
vmbox/lib/rtcwake.sh Normal file
View File

@ -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 <name> [--accel N] [--max-wait S] [--] <guest command...>"
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 &
ssh_pid=$!
if ! _wake_wait_down "$name" "$before"; then
kill "$ssh_pid" 2>/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"
}

View File

@ -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 <name> <command...>"
# 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 <name> [--uefi|--bios] <command...>"
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

View File

@ -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

155
vmbox/tests/rtcwake_e2e.sh Executable file
View File

@ -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 2>/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 </dev/null"
sleep 1
pid="$(guest 'cat /var/tmp/sleeper.pid')"
out="$(wake --accel "$ACCEL_HIBERNATE" -- "$ARM && sudo systemctl hibernate")"
armed="$(kv armed_epoch "$out")"
check "harness exit 0, result=woke" test "$(kv exit "$out")/$(kv result "$out")" = 0/woke
check "stop classified as hibernate (QMP SUSPEND_DISK)" test "$(kv down "$out")" = hibernate
check "CMOS alarm == guest wakealarm ($armed)" \
test "$armed" = "$(guest 'cat /var/lib/vmbox-wake/armed')"
check "resumed, not rebooted (boot_id unchanged)" \
test "$(guest 'cat /proc/sys/kernel/random/boot_id')" = "$before"
check "pre-hibernate sleep (pid $pid) still alive" \
guest "tr '\\0' ' ' < /proc/$pid/cmdline | grep -q '^sleep 31536000'"
read -r _ res_epoch res_id <<< "$(probe_line resume-hibernate)"
check "resume hook ran at $res_epoch >= 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 "$@"

View File

@ -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 <event> append "<event> <epoch> <boot_id>" 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 <<UNIT
[Unit]
Description=vmbox wake test: record that a boot happened, and when
[Service]
Type=oneshot
ExecStart=$SELF boot
[Install]
WantedBy=multi-user.target
UNIT
# systemd-sleep runs every executable here with (pre|post) (hibernate|...).
cat > /usr/lib/systemd/system-sleep/vmbox-wake-probe <<HOOK
#!/bin/sh
[ "\$1" = post ] && exec $SELF "resume-\$2"
exit 0
HOOK
chmod 755 /usr/lib/systemd/system-sleep/vmbox-wake-probe
systemctl daemon-reload
systemctl enable vmbox-wake-boot.service
}
case "${1:-}" in
install) install_probe ;;
'') echo "usage: $0 install | <event>" >&2; exit 2 ;;
*) record "$1" ;;
esac

View File

@ -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"* ]]
}