From 37615c85a40017e62e86a1b241a889177fcb319b Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sat, 11 Jul 2026 08:34:17 +0100 Subject: [PATCH] =?UTF-8?q?test(hibernate):=20#76=20checks.hibernate-swapf?= =?UTF-8?q?ile=20=E2=80=94=20verify=20swapfile+offset=20in=20a=20VM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- agent/BACKLOG.md | 19 +++++++++++---- agent/JOURNAL.md | 22 ++++++++++++++++++ docs/ROADMAP.md | 16 ++++++++----- flake.nix | 60 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 11 deletions(-) diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index 3a76e5f..38e1673 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -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. - `docs/MIGRATION.md` enable-hibernation runbook (existing machines). - `rofi.nix` Power → Hibernate: kept unconditional, **notifies on failure**. -- `checks.hibernate` VM test (V2 PASS) — hibernate→resume→crash round-trip; - zram (prio 100) doesn't swallow the image, disk swap carries it. +- `checks.hibernate` VM test (V2 PASS) — hibernate→resume→crash round-trip + (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 -layout (QEMU can't feed a boot-time offset / drive LUKS-initrd) + the no-swap -notify in a real session. Steps queued in HARDWARE-QUEUE. Close #76 on PASS. +**V3 remaining (narrowed):** the literal encrypted-swapfile power-cycle — +`Hibernate → power off → unlock LUKS → resume session` on the LUKS+@swap +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 diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 4e6d0e8..83579de 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -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) - **Task:** Bernardo asked for parallel work; spawned a fresh-eyes opus review of `2055842..HEAD` (static). It found the batch sound — 2 real LOW diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f4bc57a..91356f4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 **notifies on failure** (no-swap machines) rather than silently no-op'ing. Existing machines get an *enable-hibernation* runbook in `docs/MIGRATION.md` - (no tool). Guarded by two VM checks — `checks.zram-swap` (device/algo/ - priority) and `checks.hibernate` (a full hibernate→resume→crash cycle that - proves the image round-trips through disk swap and that zram at priority - 100 doesn't swallow it). The LUKS-initrd unlock + btrfs-swapfile - `resume_offset` triple is a V3 laptop check (QEMU can't feed a boot-time - offset for a file that only exists after first boot). + (no tool). Guarded by three VM checks — `checks.zram-swap` (device/algo/ + priority), `checks.hibernate` (a full hibernate→resume→crash cycle on a swap + *partition* that proves the resume mechanism + that zram at priority 100 + doesn't swallow the image), and `checks.hibernate-swapfile` (the installer's + btrfs `@swap` *swapfile* build — NOCOW `mkswapfile`, file-type swapon, a + 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` gained a read-only hibernate row group — resume device on the cmdline (+ `resume_offset` for a swapfile), disk swap ≥ RAM, zram active, and any diff --git a/flake.nix b/flake.nix index 619f246..6e17d47 100644 --- a/flake.nix +++ b/flake.nix @@ -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 # sunset and sunrise by moving the VM clock, and assert # `nomarchy-theme-sync auto` switches the active theme accordingly