diff --git a/modules/hosts/shadow/disks.nix b/modules/hosts/shadow/disks.nix index 3c44698..8b51e98 100644 --- a/modules/hosts/shadow/disks.nix +++ b/modules/hosts/shadow/disks.nix @@ -6,7 +6,7 @@ # (`nix run .#sync-hardware`) safe: re-running it can never clobber these # definitions, nor produce duplicates for whichever data drives happened to # be mounted at the time it ran. - flake.nixosModules.shadowDisks = {...}: let + flake.nixosModules.shadowDisks = {pkgs, ...}: let # Secondary data drives, addressed by filesystem label. # # nofail on all of them: it stops a missing or dying disk from becoming a @@ -74,6 +74,101 @@ "/mnt/games" = eagerDrive "GAMES"; }; - swapDevices = []; + # Swap exists for hibernation, not for memory pressure — 31GiB of RAM on a + # desktop rarely needs paging. The hibernation image must fit in swap, so the + # floor is "as big as RAM"; 40GiB leaves headroom above that. + # + # NVIDIA video memory does NOT land here: with + # NVreg_PreserveVideoMemoryAllocations=1 (set by + # hardware.nvidia.powerManagement.enable) the driver spools the 3090's VRAM + # to NVreg_TemporaryFilePath, which is unset and so defaults to /tmp. That is + # only safe because /tmp is on this ext4 root — if /tmp ever becomes a tmpfs + # (boot.tmp.useTmpfs) that VRAM dump goes into RAM and hibernation breaks. + # + # On non-btrfs filesystems NixOS creates this with `dd if=/dev/zero`, so the + # file is fully allocated rather than sparse. That matters because the kernel + # writes swap through the block layer using the extent map swapon built, + # bypassing the filesystem — so the mapping has to be stable and complete. + # + # The failure is loud, not silent, though: swapon refuses a file with holes + # outright ("skipping - it appears to have holes"), so a sparse file would + # simply leave the system with no swap rather than corrupt anything. Genuine + # silent corruption is the copy-on-write case, which is why btrfs needs + # chattr +C and a NOCOW-from-creation file. + swapDevices = [ + { + device = "/var/lib/swapfile"; + size = 40 * 1024; # MiB + } + ]; + + # Resuming from hibernation needs the kernel to be told where the image is, + # and for a swap *file* that means both halves below. + # + # boot.initrd.systemd.enable is true on this host, which makes this + # mandatory rather than optional: under systemd initrd the `resume=` kernel + # param is emitted ONLY from boot.resumeDevice. The legacy stage-1 script + # that scans swapDevices to find a resume device is not in play. Without + # this, hibernate succeeds and the machine then cold-boots, silently + # discarding the session. + # + # resume_offset is the physical block offset of the swapfile's first extent, + # because the kernel addresses the file by raw block, not by path. Recompute + # it with the following if the swapfile is ever recreated, resized, or moved + # — a stale value resumes from the wrong blocks: + # + # sudo filefrag -v /var/lib/swapfile | awk '$1=="0:" {gsub(/\.\./,"",$4); print $4}' + # + # filefrag reports in 4096-byte filesystem blocks and the kernel wants + # PAGE_SIZE units; on x86_64 both are 4096, so the number transfers directly. + boot.resumeDevice = "/dev/disk/by-uuid/deff63fc-1738-4cfc-a79c-06e58c78471a"; + boot.kernelParams = ["resume_offset=499355648"]; + + # A stale resume_offset fails *silently*: the kernel finds no swsusp + # signature at the given block, logs `PM: Image not found (code -22)`, and + # cold-boots — the hibernated session is simply gone, with no error at + # hibernate time. Since the value above is hand-copied, check it on every + # activation so drift surfaces as a warning instead of as a lost session. + # + # Two known limits, both acceptable: + # * nixpkgs only recreates the swapfile when `size` changes, so on the one + # rebuild that resizes it this check still sees the old file and passes; + # it fires on the next rebuild instead. It is a drift detector, not a + # pre-flight gate. + # * Warn rather than fail: activation must not be blocked over this, and it + # is expected to read nothing at all on a fresh install where + # mkswap-var-lib-swapfile.service has not run yet. + system.activationScripts.checkResumeOffset.text = '' + swapfile=/var/lib/swapfile + want=499355648 + if [ -f "$swapfile" ]; then + got=$(${pkgs.e2fsprogs}/bin/filefrag -v "$swapfile" 2>/dev/null \ + | ${pkgs.gawk}/bin/awk '$1=="0:" {gsub(/\.\./,"",$4); print $4; exit}') || got="" + if [ -n "$got" ] && [ "$got" != "$want" ]; then + echo "WARNING: resume_offset is pinned to $want in modules/hosts/shadow/disks.nix," >&2 + echo " but $swapfile now starts at block $got. Hibernation will cold-boot" >&2 + echo " and lose the session until boot.kernelParams is updated to $got." >&2 + fi + fi + ''; + + # This host sleeps via suspend-then-hibernate (hypridle in the dotfiles repo + # is what invokes it): S3 first so a quick return is instant, then a real + # suspend-to-disk once the machine has clearly been abandoned. + # + # Deliberately NOT hybrid-sleep, which writes the disk image up front on + # every single sleep. Measured here that cost ~59s of filesystem sync plus an + # 11GiB write before the screens even went off, and because an S3 wake + # invalidates the image the next boot always reported `PM: Image not found + # (code -22)` — full hibernation price paid every time, redeemed only on a + # power cut. suspend-then-hibernate defers that write until it is actually + # worth doing, and unlike hybrid-sleep it is a mode NVIDIA explicitly + # supports (see the system-sleep hook installed by the gpuNvidia module). + # + # HibernateDelaySec is set explicitly because systemd otherwise derives the + # handoff from battery discharge estimation, and this desktop has no system + # battery — the sole /sys/class/power_supply entry is a Logitech mouse + # reporting scope=Device, which is ignored for that purpose. + systemd.sleep.settings.Sleep.HibernateDelaySec = "30min"; }; }