Spec said `findmnt -no UUID /` and `resume=UUID=` but the script correctly uses PARTUUID throughout (matches the system's existing root=PARTUUID= convention). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.5 KiB
Lid-Close Hibernate Design
Date: 2026-05-10 Status: Approved
Summary
Make the laptop sleep more aggressively to save battery: close the lid (on AC or battery) → suspend immediately → hibernate after 30 min if not reopened. Same path applies to swayidle's existing 30-min idle timer. Reliably reaches 0% battery drain within ~60 min of stopping use, while keeping fast resume for short interruptions.
Requires one-time bootstrap to enable hibernation (swap file, kernel resume params, mkinitcpio hook, UKI regen, reboot). Day-to-day config lives in tracked dotfiles.
Current Behaviour
Lid handling (/etc/systemd/logind.conf.d/lid.conf, not currently tracked in repo):
HandleLidSwitch=suspend
HandleLidSwitchExternalPower=lock
HandleLidSwitchDocked=ignore
On battery: lid close → suspend (RAM stays powered, slow drain). On AC: lid close → just locks (laptop runs at full power overnight).
swayidle (niri/config.kdl line 39):
spawn-at-startup "swayidle" "-w" "timeout" "300" "niri msg action power-off-monitors" \
"timeout" "600" "gtklock" "-d" \
"timeout" "1800" "systemctl suspend" \
"before-sleep" "gtklock" "-d"
After 30 min idle, calls systemctl suspend (not hibernate, no auto-progression).
Hibernation prerequisites are NOT met: only zram swap (4 GiB, RAM-backed, can't persist), no resume= kernel param, no resume mkinitcpio hook. systemctl hibernate would fail.
Target Behaviour
| Trigger | Immediate action | After 30 min suspended | Result |
|---|---|---|---|
| Lid close (battery or AC) | suspend | hibernate | 0% drain |
| Idle 30 min | suspend | hibernate | 0% drain |
| Lid close while docked | ignore | — | desktop stays awake |
systemctl hibernate |
hibernate immediately | — | 0% drain, manual |
HibernateDelaySec=30min controls the suspend → hibernate transition. Tunable in one tracked file.
Implementation
Hibernation prerequisites (one-time bootstrap)
scripts/enable-hibernation.sh — idempotent script, requires sudo, run once, reboot afterward:
- Swap file.
btrfs filesystem mkswapfile --size 16g /swapfile(handles NOCOW, alignment, mkswap in one). Skip if/swapfileexists with size ≥14 GiB. - Activate.
swapon /swapfile. Append/swapfile none swap defaults 0 0to/etc/fstabif absent. - Compute resume params.
ROOT_PARTUUID=$(findmnt -no PARTUUID /)→ root partition's GPT PARTUUID (matches the existingroot=PARTUUID=…convention in/etc/kernel/cmdline)RESUME_OFFSET=$(btrfs inspect-internal map-swapfile -r /swapfile)→ physical offset of swap file
- mkinitcpio hook. Edit
/etc/mkinitcpio.confHOOKS array to insertresumebetweenblockandfilesystems.sedguarded so a re-run is a no-op. - Kernel cmdline. Append
resume=PARTUUID=$ROOT_PARTUUID resume_offset=$RESUME_OFFSETto/etc/kernel/cmdlineif not already present. - Regenerate UKI.
mkinitcpio -P→ produces/boot/EFI/Linux/arch-linux-zen.efiwith new HOOKS + cmdline baked in. - Reboot. Print explicit instruction; do not auto-reboot.
Why a separate script and not install.sh: hibernation enablement is invasive (modifies /etc/mkinitcpio.conf, /etc/kernel/cmdline, allocates 16 GiB on disk, regenerates the UKI). install.sh runs frequently during dotfiles iteration; this should run once.
Tracked dotfiles (deployed by install.sh)
| New file | Destination | Contents |
|---|---|---|
logind/lid.conf |
/etc/systemd/logind.conf.d/lid.conf |
[Login]HandleLidSwitch=suspend-then-hibernateHandleLidSwitchExternalPower=suspend-then-hibernateHandleLidSwitchDocked=ignore |
sleep/hibernate-delay.conf |
/etc/systemd/sleep.conf.d/hibernate-delay.conf |
[Sleep]HibernateDelaySec=30min |
install.sh additions
After the existing greetd block:
sudo install -Dm644 "$(pwd)/logind/lid.conf" /etc/systemd/logind.conf.d/lid.conf
sudo install -Dm644 "$(pwd)/sleep/hibernate-delay.conf" /etc/systemd/sleep.conf.d/hibernate-delay.conf
install -Dm644 creates intermediate directories if missing and sets correct mode (consistent with system file expectations).
niri/config.kdl swayidle tweak
Line 39: change "systemctl suspend" to "systemctl suspend-then-hibernate". The full line becomes:
spawn-at-startup "swayidle" "-w" "timeout" "300" "niri msg action power-off-monitors" "timeout" "600" "gtklock" "-d" "timeout" "1800" "systemctl suspend-then-hibernate" "before-sleep" "gtklock" "-d"
This makes the 30-min idle path use the same logind/sleep configuration as the lid path. After this edit, idle suspends and lid suspends are identical paths — both transition to hibernation after 30 min suspended.
README.md setup section
Add a paragraph after the existing install.sh instructions:
### One-time hibernation enablement
Hibernation requires a persistent swap file, kernel resume parameters, and a regenerated UKI. Run once, then reboot:
sudo bash scripts/enable-hibernation.sh
After reboot, `systemctl hibernate` will work, and lid close / 30-min idle will suspend-then-hibernate per the tracked logind config.
Verification
After running scripts/enable-hibernation.sh and rebooting:
swapon --show # /swapfile, ≥14 GiB, prio low
cat /proc/cmdline # contains resume=PARTUUID=… resume_offset=…
systemctl status systemd-hibernate.service
Behavioural checks:
systemctl hibernate— laptop fully powers off, then resumes to gtklock on next press of power button. (Resume takes ~20–30 s.)systemctl suspend-then-hibernate— laptop suspends instantly. After 30 min, kernel transitions to hibernation. (Don't have to wait for a real test —journalctl -u systemd-suspend-then-hibernate.serviceshows the timer firing.)- Close lid on battery → suspends. Reopen within 30 min → instant resume. Reopen after 30 min → ~30 s resume from hibernation.
- Close lid on AC → same as battery (the asymmetric "AC just locks" behavior is gone).
- Leave laptop idle 30 min → swayidle calls
systemctl suspend-then-hibernate, same path as lid.
Files Touched
| File | Action |
|---|---|
logind/lid.conf |
created |
sleep/hibernate-delay.conf |
created |
scripts/enable-hibernation.sh |
created |
install.sh |
append two sudo install lines |
niri/config.kdl |
one-line edit on line 39 (swayidle action) |
README.md |
append a "One-time hibernation enablement" subsection |
Out of Scope
- Snapshot-aware swap subvolume. No snapshot tool (snapper, btrbk) is installed, so a flat
/swapfileon the root subvolume is safe. If snapshots are added later, the swap file needs to be moved to a dedicated@swapsubvolume — a future migration, not part of this work. - Encrypted hibernation image. Root is not LUKS, so the hibernation image lands unencrypted on
/swapfile. If full-disk encryption is added later, the swap file is automatically encrypted along with the rest of root. - GUI tools (
HandleSuspendKey,HandlePowerKey, etc.) — unchanged. OnlyLid*keys are touched. HibernateDelaySecUI exposure. Tunable in the tracked file; no script or env var to flip it without editing.- Auto-reboot from
enable-hibernation.sh. The script prints a reboot instruction and exits. Rebooting is a user action. - Removing zram swap. zram stays as a fast in-memory compressed swap layer; hibernation uses
/swapfile(lower priority is fine — kernel picks higher-priority zram first under memory pressure).