# 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: 1. **Swap file.** `btrfs filesystem mkswapfile --size 16g /swapfile` (handles NOCOW, alignment, mkswap in one). Skip if `/swapfile` exists with size ≥14 GiB. 2. **Activate.** `swapon /swapfile`. Append `/swapfile none swap defaults 0 0` to `/etc/fstab` if absent. 3. **Compute resume params.** - `RESUME_UUID=$(findmnt -no UUID /)` → root filesystem UUID (currently `60cbc9b9-3631-4f9a-925c-a45e0920eb17`) - `RESUME_OFFSET=$(btrfs inspect-internal map-swapfile -r /swapfile)` → physical offset of swap file 4. **mkinitcpio hook.** Edit `/etc/mkinitcpio.conf` HOOKS array to insert `resume` between `block` and `filesystems`. `sed` guarded so a re-run is a no-op. 5. **Kernel cmdline.** Append `resume=UUID=$RESUME_UUID resume_offset=$RESUME_OFFSET` to `/etc/kernel/cmdline` if not already present. 6. **Regenerate UKI.** `mkinitcpio -P` → produces `/boot/EFI/Linux/arch-linux-zen.efi` with new HOOKS + cmdline baked in. 7. **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-hibernate`
`HandleLidSwitchExternalPower=suspend-then-hibernate`
`HandleLidSwitchDocked=ignore` | | `sleep/hibernate-delay.conf` | `/etc/systemd/sleep.conf.d/hibernate-delay.conf` | `[Sleep]`
`HibernateDelaySec=30min` | ### `install.sh` additions After the existing greetd block: ```bash 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: ```kdl 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: ```markdown ### 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: ```bash swapon --show # /swapfile, ≥14 GiB, prio low cat /proc/cmdline # contains resume=UUID=… resume_offset=… systemctl status systemd-hibernate.service ``` Behavioural checks: 1. `systemctl hibernate` — laptop fully powers off, then resumes to gtklock on next press of power button. (Resume takes ~20–30 s.) 2. `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.service` shows the timer firing.) 3. Close lid on battery → suspends. Reopen within 30 min → instant resume. Reopen after 30 min → ~30 s resume from hibernation. 4. Close lid on AC → same as battery (the asymmetric "AC just locks" behavior is gone). 5. 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 `/swapfile` on the root subvolume is safe. If snapshots are added later, the swap file needs to be moved to a dedicated `@swap` subvolume — 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. Only `Lid*` keys are touched. - **`HibernateDelaySec` UI 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).