docs: spec for lid-close hibernate (suspend-then-hibernate)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-10 16:59:23 -07:00
parent 85ef13492e
commit d2e9b47584
@@ -0,0 +1,138 @@
# 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]`<br>`HandleLidSwitch=suspend-then-hibernate`<br>`HandleLidSwitchExternalPower=suspend-then-hibernate`<br>`HandleLidSwitchDocked=ignore` |
| `sleep/hibernate-delay.conf` | `/etc/systemd/sleep.conf.d/hibernate-delay.conf` | `[Sleep]`<br>`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 ~2030 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).