test(hibernate): #76 checks.hibernate-swapfile — verify swapfile+offset in a VM
All checks were successful
Check / eval (push) Successful in 4m18s

Push #76's swapfile-resume residual into a VM as far as headlessly possible.
A full hibernate→resume from a RUNTIME swapfile turned out not to be
headless-reachable: systemd reports CanHibernate=no for a swap that isn't
declared in the boot config, and a runtime-computed resume_offset can't be
fed into the boot cmdline (the genuine chicken-and-egg). So the check now
verifies, bounded and deterministically, what the partition test
(checks.hibernate) can't and what a VM CAN prove: the installer's btrfs
@swap swapfile is built correctly and its offset is valid — NOCOW
mkswapfile, file-type swapon, a good `map-swapfile -r` offset the kernel
accepts (/sys/power/resume_offset), and zram (prio 100) above it.

The literal encrypted-swapfile power-cycle + LUKS-initrd unlock stays a V3
laptop check (the dev box runs exactly this layout, /proc/cmdline verified).

(First cut had an unbounded wait_for_shutdown that hung on the CanHibernate
refusal; rewritten with a thread-bounded wait + a CanHibernate pre-check,
which gave the clean verdict. Device is /dev/vdb without useNixStoreImage.)

Verification: V2 PASS (nix build .#checks.x86_64-linux.hibernate-swapfile,
fresh, exit 0); flake check --no-build green. #76 residual narrowed in
BACKLOG/ROADMAP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-07-11 08:34:17 +01:00
parent 2fa5231215
commit 37615c85a4
4 changed files with 106 additions and 11 deletions

View File

@@ -39,12 +39,21 @@ not Nomarchy, so it can't close it). Steps in HARDWARE-QUEUE. Design record in
`@swap` swapfile + `resume_offset` wiring; `swapSize=0` = no-swap opt-out. `@swap` swapfile + `resume_offset` wiring; `swapSize=0` = no-swap opt-out.
- `docs/MIGRATION.md` enable-hibernation runbook (existing machines). - `docs/MIGRATION.md` enable-hibernation runbook (existing machines).
- `rofi.nix` Power → Hibernate: kept unconditional, **notifies on failure**. - `rofi.nix` Power → Hibernate: kept unconditional, **notifies on failure**.
- `checks.hibernate` VM test (V2 PASS) — hibernate→resume→crash round-trip; - `checks.hibernate` VM test (V2 PASS) — hibernate→resume→crash round-trip
zram (prio 100) doesn't swallow the image, disk swap carries it. (swap partition); zram (prio 100) doesn't swallow the image, disk carries it.
- `checks.hibernate-swapfile` VM test (V2 PASS) — the installer's btrfs `@swap`
**swapfile** setup: NOCOW `mkswapfile`, file-type swapon, a valid
`map-swapfile -r` offset the kernel accepts (`/sys/power/resume_offset`),
zram above it. (Bounded proof of the swapfile+offset the partition test
can't reach; a full runtime-swapfile hibernate isn't headless-reachable —
systemd `CanHibernate=no` for undeclared swap.)
**V3 remaining:** real hibernate→resume on the LUKS+@swap swapfile+offset **V3 remaining (narrowed):** the literal encrypted-swapfile power-cycle —
layout (QEMU can't feed a boot-time offset / drive LUKS-initrd) + the no-swap `Hibernate → power off → unlock LUKS → resume session` on the LUKS+@swap
notify in a real session. Steps queued in HARDWARE-QUEUE. Close #76 on PASS. layout — plus the no-swap notify in a real session. QEMU can't feed a
runtime-computed `resume_offset` into the boot cmdline, and the dev box runs
exactly this layout (`/proc/cmdline` verified) but isn't Nomarchy. Steps in
HARDWARE-QUEUE. Close #76 on PASS.
## LATER ## LATER

View File

@@ -17,6 +17,28 @@ Template:
--- ---
## 2026-07-11 — #76 checks.hibernate-swapfile (VM-verify the swapfile+offset)
- **Task:** Bernardo — push #76's swapfile-resume residual into a VM (like
the auto-theme sunset trick).
- **Did:** Tried a full runtime-swapfile hibernate→resume test; it hit a
bounded, definitive **`CanHibernate=no`** — systemd won't hibernate to a
swap that isn't declared in the boot config, and a runtime-computed
`resume_offset` can't reach the boot cmdline (the genuine chicken-and-egg).
First cut also had an **unbounded `wait_for_shutdown`** that hung → killed
it, bounded the wait in a thread (180s) + a `CanHibernate` pre-check, which
is what surfaced the clean verdict. Pivoted `checks.hibernate-swapfile` to
what a VM *can* prove deterministically and the partition test can't: the
installer swapfile **build + offset** — NOCOW `mkswapfile`, file-type
swapon, valid `map-swapfile -r` offset accepted by `/sys/power/resume_offset`,
zram above it. (Device fix: `/dev/vdb` once `useNixStoreImage` was dropped.)
- **Verified:** **V2 PASS** (fresh build, exit 0). BACKLOG/ROADMAP updated;
#76 residual narrowed to the literal encrypted power-cycle (dev box runs it).
- **Lesson:** the sunset-trick spirit applies but not always the full cycle —
when systemd/kernel gates the behaviour (CanHibernate), verify the
*construction* deterministically and leave only the physical cycle to HW.
Always bound `wait_for_shutdown` (no timeout param → thread + join).
- **Next suggestion:** #76/#79 both `[blocked:hw]`; QA / PROPOSED otherwise.
## 2026-07-11 — Code-review fixes (parallel review of the session batch) ## 2026-07-11 — Code-review fixes (parallel review of the session batch)
- **Task:** Bernardo asked for parallel work; spawned a fresh-eyes opus - **Task:** Bernardo asked for parallel work; spawned a fresh-eyes opus
review of `2055842..HEAD` (static). It found the batch sound — 2 real LOW review of `2055842..HEAD` (static). It found the batch sound — 2 real LOW

View File

@@ -345,12 +345,16 @@ Design/decision records and a running log of shipped work (items marked
no-swap opt-out; the Power-menu Hibernate row is kept unconditional and no-swap opt-out; the Power-menu Hibernate row is kept unconditional and
**notifies on failure** (no-swap machines) rather than silently no-op'ing. **notifies on failure** (no-swap machines) rather than silently no-op'ing.
Existing machines get an *enable-hibernation* runbook in `docs/MIGRATION.md` Existing machines get an *enable-hibernation* runbook in `docs/MIGRATION.md`
(no tool). Guarded by two VM checks — `checks.zram-swap` (device/algo/ (no tool). Guarded by three VM checks — `checks.zram-swap` (device/algo/
priority) and `checks.hibernate` (a full hibernate→resume→crash cycle that priority), `checks.hibernate` (a full hibernate→resume→crash cycle on a swap
proves the image round-trips through disk swap and that zram at priority *partition* that proves the resume mechanism + that zram at priority 100
100 doesn't swallow it). The LUKS-initrd unlock + btrfs-swapfile doesn't swallow the image), and `checks.hibernate-swapfile` (the installer's
`resume_offset` triple is a V3 laptop check (QEMU can't feed a boot-time btrfs `@swap` *swapfile* build — NOCOW `mkswapfile`, file-type swapon, a
offset for a file that only exists after first boot). valid `map-swapfile -r` offset the kernel accepts, zram above it). A full
hibernate from a *runtime* swapfile isn't headless-reachable (systemd
`CanHibernate=no` for undeclared swap; and a runtime-computed `resume_offset`
can't be fed into the boot cmdline), so the literal encrypted-swapfile
power-cycle + LUKS-initrd unlock stays a V3 laptop check.
✓ **Doctor hibernate/sleep section (#77, `VISION § C`):** `nomarchy-doctor` ✓ **Doctor hibernate/sleep section (#77, `VISION § C`):** `nomarchy-doctor`
gained a read-only hibernate row group — resume device on the cmdline gained a read-only hibernate row group — resume device on the cmdline
(+ `resume_offset` for a swapfile), disk swap ≥ RAM, zram active, and any (+ `resume_offset` for a swapfile), disk swap ≥ RAM, zram active, and any

View File

@@ -752,6 +752,66 @@
''; '';
}; };
# The installer's BTRFS @swap SWAPFILE setup (#76) — the parts the
# partition hibernate test (checks.hibernate) can't reach. A full
# hibernate→resume from a *runtime* swapfile isn't reachable
# headlessly: systemd reports CanHibernate=no unless the swap is
# declared in the boot config, and feeding a runtime-computed
# resume_offset into the boot cmdline is a genuine chicken-and-egg.
# So the power-cycle stays a real-hardware check (the dev box runs
# exactly this layout — /proc/cmdline verified). What IS verified
# here, bounded and deterministic, is that the swapfile is built
# correctly and its resume offset is valid: NOCOW `mkswapfile`, a
# good `map-swapfile -r` offset the kernel accepts, swapon as a file,
# and zram (prio 100) above it so the image lands on disk, not zram.
hibernate-swapfile = pkgs.testers.runNixOSTest {
name = "nomarchy-hibernate-swapfile";
nodes.machine = { config, lib, pkgs, ... }: {
imports = [ ./modules/nixos/oom.nix ]; # zram default-on (#76)
virtualisation.memorySize = 2048;
# A dedicated disk for the btrfs @swap subvolume.
virtualisation.emptyDiskImages = [ (4 * config.virtualisation.memorySize) ];
environment.systemPackages = [ pkgs.btrfs-progs ];
};
testScript = ''
machine.wait_for_unit("default.target")
machine.wait_until_succeeds("grep -q /dev/zram0 /proc/swaps", timeout=60)
# Reproduce the installer layout: btrfs, @swap subvolume, then a
# NOCOW swapfile via `btrfs filesystem mkswapfile`.
# The lone empty data disk (root is vda; no separate store disk
# here since useNixStoreImage is off).
machine.succeed("mkfs.btrfs -f /dev/vdb")
machine.succeed(
"mkdir -p /mnt-btrfs && mount /dev/vdb /mnt-btrfs "
"&& btrfs subvolume create /mnt-btrfs/@swap && umount /mnt-btrfs"
)
machine.succeed("mkdir -p /swap && mount -o subvol=@swap /dev/vdb /swap")
machine.succeed("btrfs filesystem mkswapfile --size 3g --uuid clear /swap/swapfile")
# NOCOW is mandatory a copy-on-write swapfile corrupts on btrfs.
machine.succeed("lsattr /swap/swapfile | grep -q C")
machine.succeed("swapon /swap/swapfile")
machine.succeed(
"awk '$1==\"/swap/swapfile\" && $2==\"file\"{f=1} END{exit !f}' /proc/swaps"
)
# The Nomarchy-specific offset math (patch-template bakes this
# into resume_offset): a valid physical offset the kernel accepts.
offset = machine.succeed(
"btrfs inspect-internal map-swapfile -r /swap/swapfile"
).strip()
assert offset.isdigit() and int(offset) > 0, f"bad resume offset: {offset!r}"
machine.succeed(f"echo {offset} > /sys/power/resume_offset")
# zram (prio 100) must sit above the disk swapfile, so a hibernate
# image would land on disk, not volatile zram.
machine.succeed(
"awk '$1==\"/dev/zram0\"&&$5==100{z=1} "
"$1==\"/swap/swapfile\"&&$5<100{d=1} END{exit !(z&&d)}' /proc/swaps"
)
'';
};
# Auto time-of-day theme (#79, VISION § D): simulate crossing # Auto time-of-day theme (#79, VISION § D): simulate crossing
# sunset and sunrise by moving the VM clock, and assert # sunset and sunrise by moving the VM clock, and assert
# `nomarchy-theme-sync auto` switches the active theme accordingly # `nomarchy-theme-sync auto` switches the active theme accordingly