add a 40gib swapfile and wire up hibernation on shadow

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. the 3090's vram does not
land here: with NVreg_PreserveVideoMemoryAllocations=1 the driver spools it
to NVreg_TemporaryFilePath, which nixpkgs leaves unset and so defaults to
/tmp. that is only safe while /tmp is on the real root - boot.tmp.useTmpfs
would route a full vram dump into ram and break hibernation outright.

resuming needs both boot.resumeDevice and resume_offset. the device half is
mandatory rather than optional here because boot.initrd.systemd.enable is
true, and under systemd initrd the resume= parameter is emitted only from
boot.resumeDevice - the legacy stage-1 script that scans swapDevices is not
in play. without it 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,
since the kernel addresses the file by raw block. that value is hand-copied,
and a stale one fails silently: no swsusp signature at the given block means
`PM: Image not found (code -22)` and a cold boot, with no error at hibernate
time. so an activation check warns on drift. it is a detector, not a gate -
nixpkgs only recreates the swapfile when size changes, so on the rebuild
that resizes it the check still sees the old file and fires on the next one.
it warns rather than fails, and expects to read nothing on a fresh install
where mkswap-var-lib-swapfile.service has not run yet.

sleeping via suspend-then-hibernate (hypridle 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 at ~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 `Image not found`.
full hibernation price paid every time, redeemed only on a power cut.
suspend-then-hibernate defers that write until it is worth doing, and unlike
hybrid-sleep it is a mode nvidia explicitly supports.

HibernateDelaySec is explicit 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.

also corrects a comment: a sparse swapfile does not silently corrupt.
swapon refuses files with holes outright ("skipping - it appears to have
holes"), so the failure is loud and the system simply ends up with no swap.
genuine silent corruption is the copy-on-write case, which is why btrfs
needs a NOCOW-from-creation file.
This commit is contained in:
2026-08-06 17:09:14 +01:00
parent 67bafd4f73
commit 29707ba0f6
+97 -2
View File
@@ -6,7 +6,7 @@
# (`nix run .#sync-hardware`) safe: re-running it can never clobber these # (`nix run .#sync-hardware`) safe: re-running it can never clobber these
# definitions, nor produce duplicates for whichever data drives happened to # definitions, nor produce duplicates for whichever data drives happened to
# be mounted at the time it ran. # be mounted at the time it ran.
flake.nixosModules.shadowDisks = {...}: let flake.nixosModules.shadowDisks = {pkgs, ...}: let
# Secondary data drives, addressed by filesystem label. # Secondary data drives, addressed by filesystem label.
# #
# nofail on all of them: it stops a missing or dying disk from becoming a # nofail on all of them: it stops a missing or dying disk from becoming a
@@ -74,6 +74,101 @@
"/mnt/games" = eagerDrive "GAMES"; "/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";
}; };
} }