test(hibernate): #76 slice 3 — V2 hibernate→resume VM check (PASS)
All checks were successful
Check / eval (push) Successful in 4m11s

Add checks.hibernate: a runNixOSTest modeled on nixpkgs' hibernate.nix,
wired to the actual risk #76 introduces — coexistence with the zram swap
that oom.nix now enables by default. It imports oom.nix, puts the disk
swap at a lower priority than zram with an explicit boot.resumeDevice,
and uses systemd-initrd. The test drives a full
hibernate -> power-off -> resume -> crash cycle: a volatile ramfs marker
survives resume but NOT a crash-boot (proving genuine resume), and it
asserts zram sits at priority 100 while the disk swap is lower, so the
hibernate image lands on disk rather than volatile zram.

What QEMU genuinely can't do — feed a boot-time resume_offset for a btrfs
swapfile (offset only exists after first-boot allocation) or drive a
LUKS-initrd unlock — stays a V3 laptop check (HARDWARE-QUEUE, dev box
already runs that exact layout), plus the no-swap notify in a real
session. With this, all V0/V1/V2 work on #76 is done; #76 collapses to a
[blocked:hw] stub and the design record moves to ROADMAP.

Verification: V2 PASS — nix build .#checks.x86_64-linux.hibernate built
the driver fresh and exited 0. nix flake check --no-build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-07-10 19:45:47 +01:00
parent 2d0cb48d5c
commit ba8963a385
5 changed files with 140 additions and 65 deletions

View File

@@ -28,72 +28,22 @@ in [`docs/ROADMAP.md`](../docs/ROADMAP.md); map in
## NEXT
### 76. Hibernation default `[big]`
**Product intent (Bernardo 2026-07-10):** hibernation on by default;
zram on alongside for live memory pressure. Disk swap ≥ RAM for the
hibernate image; zram high-priority for day-to-day swap (not sufficient
alone for resume).
### 76. Hibernation default — all agent work shipped `[blocked:hw]`
Hibernation + zram on by default (product intent, Bernardo 2026-07-10).
**All V0/V1/V2 slices done** (2026-07-10); only the V3 laptop check remains
(HARDWARE-QUEUE → "AMD dev box"). Design record in **ROADMAP § Hibernation +
zram by default**. Shipped:
- zram default (`modules/nixos/oom.nix`, zstd/50%/prio 100) — `checks.zram-swap`.
- Installer/template already default new installs to a RAM-sized encrypted
`@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.
**✓ zram slice shipped (2026-07-10):** `zramSwap` on by default in
`modules/nixos/oom.nix` (zstd, 50% RAM, priority 100 so day-to-day paging
stays off the disk swap reserved for the hibernate image); `checks.zram-swap`
VM test asserts device/algorithm/priority (V2 pass). **Remainder below is the
hibernation half** — disk swapfile default + resume + installer.
**Reference layout (this machine — already hibernation-capable, no zram
yet):** LUKS (`crypto_LUKS` → mapper `crypted`) holds one BTRFS with
`@swap` mounted at `/swap`; swapfile `/swap/swapfile` (here 16G);
`swapDevices = [{ device = "/swap/swapfile"; }]`;
`boot.resumeDevice = "/dev/disk/by-uuid/<LUKS-root-uuid>"`;
`boot.kernelParams = [ "resume_offset=…" ]` from
`btrfs inspect-internal map-swapfile -r`. The swapfile lives **inside
the encrypted volume** (subvolume, not a separate cleartext partition).
Installer path already sketches this (`@swap` + resume_offset patch in
`nomarchy-install` / `patch-template.py`) when `swapSize` ≥ RAM-ish.
**✓ Design settled (Bernardo 2026-07-10):**
- **Swap sizing** — keep **exactly RAM** (rounded up to whole GiB), as the
installer already does. Hibernate image ≤ RAM; zram (priority 100) takes
day-to-day paging so the file stays reserved for the image. No change.
- **`swapSize=0`** — stays a no-swap / no-resume opt-out (already handled by
installer + patch-template: no `@swap`, no `swapDevices`/resume wiring).
- **Migration (existing machines)** — **docs runbook in `docs/MIGRATION.md`**,
not a tool: create `@swap` subvol + swapfile, `map-swapfile -r` offset,
add `swapDevices`/`boot.resumeDevice`/`resume_offset` to `system.nix`.
- **No-swap Hibernate UX** — **keep the Power-menu Hibernate row**; when
hibernate fails (no swap), show a desktop **notification** explaining no
swap is configured (rather than hiding the row or a silent no-op).
**Already shipped for NEW installs** (verify, don't rebuild): installer
defaults `NOMARCHY_SWAP_GB=RAM` → hibernation-ready `@swap` swapfile encrypted
with root; `patch-template.py` writes `swapDevices` + `boot.resumeDevice` +
`resume_offset`; Power menu has a Hibernate row; hibernate+LUKS+hyprlock
interplay handled in `modules/nixos/default.nix` / `modules/home/idle.nix`.
**Remaining slices:**
1. ~~**Docs** — `docs/MIGRATION.md` hibernation-enable runbook (V0).~~
shipped 2026-07-10 (§ "Enabling hibernation on an existing machine";
commands verified live against the dev machine's LUKS+@swap layout).
2. ~~**Menu** — Hibernate row: notify-on-failure when no swap configured.~~
✓ shipped 2026-07-10 (`rofi.nix` power arm: `systemctl hibernate ||
notify-send …`; success blocks until resume so the notify only fires on
real failure). V1 (HM generation builds; `writeShellScriptBin` bash -n) +
control-flow proof (fail→notify, success→no notify). Row kept unconditional.
The session-level "no-swap pick surfaces a mako notification" is folded
into the V3 laptop check below.
3. **Verify (required)**
- **V0:** eval / option / disko contracts.
- **V2 (mandatory, agent):** exercise hibernation in the **VM harness**
(`runNixOSTest` or install-test style) — encrypted root + BTRFS
`@swap` swapfile + resume_offset; prove hibernate→resume path at
least as far as QEMU allows (fail the item if only “config
evaluates”).
- **V3:** real laptop (this LUKS+@swap layout): Hibernate → power off
→ resume session; queue HARDWARE-QUEUE.
**Out of scope:** TLP; formatter. Prefer boring NixOS knobs; extend the
existing installer swapfile story rather than inventing a second layout.
**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.
## LATER

View File

@@ -290,6 +290,27 @@ that run; leftover open items still need a pass.*
screenshot loaded. Check that the UI draws on the current theme palette
(tools colored properly) and hitting save places the screenshot in
`~/Pictures/Screenshots/` while hitting copy places it in the clipboard.
- [ ] **#76 hibernate→resume on the real LUKS+@swap layout** — the V2 VM test
proves the resume mechanism + zram coexistence, but QEMU can't feed a
boot-time `resume_offset` for a btrfs *swapfile* nor drive the LUKS
initrd unlock, so this triple needs hardware (dev box already runs it:
LUKS mapper `cryptroot`, btrfs `@`, `/swap/swapfile`).
1. Confirm the layout: `swapon --show` lists `/swap/swapfile` (Type file)
**and** `/dev/zram0` at priority 100; `cat /proc/cmdline` shows
`resume_offset=…`; `nixos-option boot.resumeDevice` is the btrfs UUID.
2. Open apps, note an unsaved in-RAM state (e.g. a terminal `echo` scroll,
an editor buffer). Run `nomarchy-menu → Power → Hibernate` (or
`systemctl hibernate`). Machine writes the image and **powers off**.
3. Power on: you unlock LUKS **once** in the themed prompt and land back
in the **same session** — windows/state restored, not a fresh login.
4. **Pass** = session restored after a full power-off + single unlock.
Fail (fresh boot / stuck initrd) → NOW bug in BACKLOG (capture
`journalctl -b -1 | grep -i -E 'hibernat|resume|swap'`).
- [ ] **#76 no-swap Hibernate notify** — on a machine installed with `swap = 0`
(or temporarily `sudo swapoff -a`): `nomarchy-menu → Power → Hibernate`
must surface a desktop notification ("Couldn't hibernate — likely no
swap is configured. See docs/MIGRATION.md → Enabling hibernation."),
**not** a silent no-op. (`swapon` after, if you swapoff'd for the test.)
## AMD dev box only
- [ ] **AMD runtime bits** — VA-API (`vainfo` → radeonsi), amd-pstate EPP

View File

@@ -17,6 +17,24 @@ Template:
---
## 2026-07-10 — #76 slice 3: hibernate VM test (V2 PASS) → #76 agent-complete
- **Task:** BACKLOG #76 slice 3 — the mandatory V2 hibernate test + V3 queue.
- **Did:** Added `checks.hibernate` (flake.nix), modeled on nixpkgs'
hibernate.nix but wired to Nomarchy's #76 risk: imports oom.nix (zram
default-on), disk swap at lower priority + explicit `boot.resumeDevice`,
systemd-initrd. Test drives hibernate→power-off→resume→crash: ramfs marker
survives resume, a crash-boot loses it (real resume, not a survivor), and
asserts zram@100 + disk-swap<100 so the image lands on disk not volatile
zram. Queued the V3 laptop steps (real LUKS+@swap+offset resume + no-swap
notify) in HARDWARE-QUEUE. Collapsed #76 to a `[blocked:hw]` stub; design
record → ROADMAP § "Hibernation + zram by default".
- **Verified:** **V2 PASS**`nix build .#checks.x86_64-linux.hibernate`
built the driver fresh and exited 0 (all assertions held). `nix flake
check --no-build` green.
- **Pending:** only V3 (dev box / laptop) — #76's last check; close on PASS.
- **Next suggestion:** #76 is now blocked:hw; NEXT head falls to LATER/
PROPOSED triage or a QA sweep — pick per LOOP.md orientation next session.
## 2026-07-10 — #76 slice 2: Hibernate notify-on-failure
- **Task:** BACKLOG #76 remaining slice 2 — per the settled "keep row, notify
on failure" call.

View File

@@ -305,6 +305,24 @@ Design/decision records and a running log of shipped work (items marked
logo/lock/bullet → text, entry/progress-box → surface, progress-bar →
accent. Reads on light and dark; follows the theme as of the last system
rebuild (like the background tint).
- ✓ **Hibernation + zram by default (BACKLOG #76):** two memory layers ship
on by default. `modules/nixos/oom.nix` enables `zramSwap` (zstd, 50% RAM,
**priority 100**) as the compressed-RAM layer *before* earlyoom; the
installer defaults the disk swap to a **RAM-sized** hibernation-ready
swapfile on an `@swap` btrfs subvolume *inside* the encrypted volume, with
`patch-template.py` wiring `swapDevices` + `boot.resumeDevice` +
`resume_offset` (from `btrfs inspect-internal map-swapfile -r`). The
priority split is the point: day-to-day paging stays in zram, so the disk
swapfile is reserved for the hibernate image. `swapSize=0` stays a clean
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).
- ✓ **Hibernate double-unlock:** on resume from hibernate the LUKS
passphrase already gates the machine, but locking hyprlock before sleep
meant a second password. Fixed by *not locking* before an encrypted

View File

@@ -684,6 +684,74 @@
'';
};
# Hibernate → resume (BACKLOG #76): the load-bearing risk the
# hibernation default introduces is coexistence with the zram
# swap that oom.nix now enables by default. Prove, in the VM
# harness, that (a) a machine hibernates and resumes for real —
# a volatile ramfs marker survives resume but NOT a fresh boot —
# and (b) zram (priority 100) does not swallow the hibernate
# image: the disk swap (lower priority, explicit resumeDevice)
# carries it. Modeled on nixpkgs' hibernate.nix (partition swap +
# systemd-initrd resume). What QEMU genuinely can't do is feed a
# boot-time `resume_offset` for a btrfs *swapfile* (the offset
# only exists after the file is allocated on first boot) or drive
# a LUKS-initrd unlock end-to-end — so the encrypted @swap
# swapfile + offset path stays a V3 laptop check (HARDWARE-QUEUE),
# which already runs that exact layout. This test imports oom.nix
# so it tracks the real zram default, not a hand-copy.
hibernate = pkgs.testers.runNixOSTest {
name = "nomarchy-hibernate";
nodes.machine = { config, lib, pkgs, ... }: {
imports = [ ./modules/nixos/oom.nix ]; # zram default-on (#76)
virtualisation.memorySize = 2048;
# Store + EFI must persist across the hibernate/resume/crash
# boots; a disk swap ≥ 2×RAM holds the image.
virtualisation.useNixStoreImage = true;
virtualisation.useEFIBoot = true;
virtualisation.emptyDiskImages = [ (2 * config.virtualisation.memorySize) ];
boot.initrd.systemd.enable = true; # systemd-initrd, like Nomarchy
# Disk swap at a LOWER priority than zram (100); explicit
# resumeDevice — exactly how the installer wires hibernation —
# so the image goes to disk regardless of zram's priority.
swapDevices = lib.mkOverride 0 [
{ device = "/dev/vdc"; options = [ "x-systemd.makefs" ]; priority = 1; }
];
boot.resumeDevice = "/dev/vdc";
# Let the driver's shell reconnect after resume.
systemd.services.backdoor.conflicts = [ "sleep.target" ];
powerManagement.resumeCommands = "systemctl --no-block restart backdoor.service";
};
testScript = ''
machine.wait_for_unit("default.target")
# zram (oom.nix) up at priority 100; disk swap present and lower
# so the hibernate image lands on disk, not volatile zram.
machine.wait_until_succeeds("grep -q /dev/zram0 /proc/swaps", timeout=60)
machine.succeed(
"awk '$1==\"/dev/zram0\"&&$5==100{z=1} "
"$1==\"/dev/vdc\"&&$5<100{d=1} END{exit !(z&&d)}' /proc/swaps"
)
# Marker in volatile RAM: survives a resume, never a fresh boot.
machine.succeed(
"mkdir /run/test",
"mount -t ramfs -o size=1m ramfs /run/test",
"echo not persisted to disk > /run/test/suspended",
)
# Hibernate, wait for real power-off, then resume.
machine.execute("systemctl hibernate >&2 &", check_return=False)
machine.wait_for_shutdown()
machine.start()
machine.succeed("grep 'not persisted to disk' /run/test/suspended")
# Prove it was a genuine resume: a crash-boot must lose the marker.
machine.crash()
machine.wait_for_unit("default.target")
machine.fail("grep 'not persisted to disk' /run/test/suspended")
'';
};
# Snapshot GUI canary: btrfs-assistant crashes in libbtrfsutil's
# UNPRIVILEGED subvolume iteration (btrfs-progs 6.17.1, fixed
# upstream after 6.17.1) but runs fine as root — which is how the