Files
dotfiles/docs/superpowers/specs/2026-05-10-lid-hibernate-design.md
T
funman300 d2e9b47584 docs: spec for lid-close hibernate (suspend-then-hibernate)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:59:23 -07:00

139 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).