docs: implementation plan for lid-close hibernate
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
# Lid-Close Hibernate Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make lid close (battery or AC) and 30-min idle both reach hibernation via the kernel's `suspend-then-hibernate` mode, after first enabling hibernation on the system.
|
||||
|
||||
**Architecture:** Two layers. (1) A one-time, idempotent bootstrap script (`scripts/enable-hibernation.sh`) that creates a 16 GiB btrfs swap file, adds the `resume` mkinitcpio hook, appends `resume=` + `resume_offset=` to the kernel cmdline, and regenerates the UKI. Reboot required after. (2) Tracked dotfiles (logind drop-in, sleep drop-in, niri swayidle line) deployed by `install.sh` as usual — no reboot needed for these.
|
||||
|
||||
**Tech Stack:** bash, systemd-logind, systemd-sleep, mkinitcpio, btrfs, ukify (via `mkinitcpio -P`), niri.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-10-lid-hibernate-design.md`
|
||||
|
||||
**Verification model:** Bash + system commands. Subagents can `bash -n` shell scripts and read INI files but cannot run sudo or reboot. Each task ends with a syntax check + a list of one-line user-side post-deploy verifications the controller will run.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `scripts/enable-hibernation.sh` | One-time idempotent bootstrap. Creates swap, edits /etc/mkinitcpio.conf, /etc/kernel/cmdline, regenerates UKI. |
|
||||
| `logind/lid.conf` | Tracked drop-in for `/etc/systemd/logind.conf.d/` — sets all three lid handlers to `suspend-then-hibernate` (or `ignore` for docked). |
|
||||
| `sleep/hibernate-delay.conf` | Tracked drop-in for `/etc/systemd/sleep.conf.d/` — sets `HibernateDelaySec=30min`. |
|
||||
| `install.sh` | Modified: adds two `sudo install -Dm644` lines to deploy the drop-ins. |
|
||||
| `niri/config.kdl` | Modified: line 39 swayidle action `systemctl suspend` → `systemctl suspend-then-hibernate`. |
|
||||
| `README.md` | Modified: appended subsection "One-time hibernation enablement" with the run-once instruction. |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Bootstrap script
|
||||
|
||||
After this task, `sudo bash scripts/enable-hibernation.sh` enables hibernation end-to-end. Re-runs are no-ops. The script does NOT auto-reboot — it prints instructions.
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/enable-hibernation.sh`
|
||||
|
||||
- [ ] **Step 1: Create the script**
|
||||
|
||||
Write `scripts/enable-hibernation.sh` with the following exact content:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# enable-hibernation.sh — one-time setup for laptop hibernation on btrfs.
|
||||
# Idempotent: re-runs are no-ops once everything is in place. Run with sudo.
|
||||
# After successful run, reboot before testing `systemctl hibernate`.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Run as root: sudo bash $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SWAPFILE=/swapfile
|
||||
SWAPSIZE=16g
|
||||
ROOT_PARTUUID=$(findmnt -no PARTUUID /)
|
||||
|
||||
echo "==> Step 1/6: ensure swap file exists at $SWAPFILE (size $SWAPSIZE)"
|
||||
if [ ! -f "$SWAPFILE" ]; then
|
||||
btrfs filesystem mkswapfile --size "$SWAPSIZE" "$SWAPFILE"
|
||||
echo " created $SWAPFILE"
|
||||
else
|
||||
actual_bytes=$(stat -c %s "$SWAPFILE")
|
||||
min_bytes=$((14 * 1024 * 1024 * 1024))
|
||||
if [ "$actual_bytes" -lt "$min_bytes" ]; then
|
||||
echo "ERROR: $SWAPFILE exists but is smaller than 14 GiB (got $actual_bytes bytes)." >&2
|
||||
echo " Remove it manually and re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " already exists ($((actual_bytes / 1024 / 1024 / 1024)) GiB) — skipping creation"
|
||||
fi
|
||||
|
||||
echo "==> Step 2/6: activate swap and persist in fstab"
|
||||
if ! swapon --show | grep -q "^$SWAPFILE "; then
|
||||
swapon "$SWAPFILE"
|
||||
echo " swapon $SWAPFILE"
|
||||
else
|
||||
echo " already active — skipping swapon"
|
||||
fi
|
||||
if ! grep -qE "^$SWAPFILE\s" /etc/fstab; then
|
||||
printf '%s\tnone\tswap\tdefaults\t0 0\n' "$SWAPFILE" >> /etc/fstab
|
||||
echo " appended to /etc/fstab"
|
||||
else
|
||||
echo " /etc/fstab already references $SWAPFILE — skipping"
|
||||
fi
|
||||
|
||||
echo "==> Step 3/6: compute resume params"
|
||||
RESUME_OFFSET=$(btrfs inspect-internal map-swapfile -r "$SWAPFILE")
|
||||
echo " resume=PARTUUID=$ROOT_PARTUUID"
|
||||
echo " resume_offset=$RESUME_OFFSET"
|
||||
|
||||
echo "==> Step 4/6: ensure 'resume' hook in /etc/mkinitcpio.conf"
|
||||
if grep -qE "^HOOKS=\(.*\bresume\b.*\)" /etc/mkinitcpio.conf; then
|
||||
echo " resume hook already present — skipping"
|
||||
else
|
||||
sed -i -E 's/(^HOOKS=\(.*\bblock\b)/\1 resume/' /etc/mkinitcpio.conf
|
||||
if ! grep -qE "^HOOKS=\(.*\bresume\b.*\)" /etc/mkinitcpio.conf; then
|
||||
echo "ERROR: failed to insert 'resume' hook. Check /etc/mkinitcpio.conf manually." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " inserted 'resume' after 'block'"
|
||||
fi
|
||||
|
||||
echo "==> Step 5/6: append resume params to /etc/kernel/cmdline"
|
||||
if grep -q "resume=" /etc/kernel/cmdline; then
|
||||
echo " cmdline already contains resume= — skipping"
|
||||
else
|
||||
sed -i "1 s|\$| resume=PARTUUID=$ROOT_PARTUUID resume_offset=$RESUME_OFFSET|" /etc/kernel/cmdline
|
||||
echo " appended to /etc/kernel/cmdline"
|
||||
fi
|
||||
|
||||
echo "==> Step 6/6: regenerate UKI"
|
||||
mkinitcpio -P
|
||||
|
||||
echo
|
||||
echo "Done. Reboot to activate hibernation:"
|
||||
echo " sudo reboot"
|
||||
echo
|
||||
echo "After reboot, test with: systemctl hibernate"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Make it executable**
|
||||
|
||||
```bash
|
||||
chmod +x scripts/enable-hibernation.sh
|
||||
ls -la scripts/enable-hibernation.sh
|
||||
```
|
||||
|
||||
Expected: file mode shows `-rwxr-xr-x` (or similar with `x` bits set).
|
||||
|
||||
- [ ] **Step 3: Syntax check**
|
||||
|
||||
```bash
|
||||
bash -n scripts/enable-hibernation.sh && echo "syntax ok"
|
||||
```
|
||||
|
||||
Expected: `syntax ok` and exit 0.
|
||||
|
||||
- [ ] **Step 4: Dry-run verify the idempotency guards (without sudo)**
|
||||
|
||||
The script bails on the EUID check first if not root, so we can safely "run" it as a non-root user to confirm the bail-out:
|
||||
|
||||
```bash
|
||||
bash scripts/enable-hibernation.sh 2>&1 | head -3
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
Run as root: sudo bash scripts/enable-hibernation.sh
|
||||
```
|
||||
Exit code 1.
|
||||
|
||||
Also visually inspect the script — confirm each of the six steps has a guard (`if [ ! -f ]`, `if ! grep`, etc.) so a re-run is a no-op.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/enable-hibernation.sh
|
||||
git commit -m "scripts: add enable-hibernation.sh (one-time bootstrap)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Tracked drop-in configs + `install.sh` deploy
|
||||
|
||||
After this task, the repo carries the two drop-in files and `install.sh` deploys them. Re-running `install.sh` is safe and overwrites the live drop-ins to match the tracked versions (matches the existing greetd pattern).
|
||||
|
||||
**Files:**
|
||||
- Create: `logind/lid.conf`
|
||||
- Create: `sleep/hibernate-delay.conf`
|
||||
- Modify: `install.sh` (add two lines after the existing `==> Deploying greetd config` block)
|
||||
|
||||
- [ ] **Step 1: Create `logind/lid.conf`**
|
||||
|
||||
```bash
|
||||
mkdir -p logind
|
||||
```
|
||||
|
||||
Write `logind/lid.conf` with this exact content:
|
||||
|
||||
```ini
|
||||
[Login]
|
||||
HandleLidSwitch=suspend-then-hibernate
|
||||
HandleLidSwitchExternalPower=suspend-then-hibernate
|
||||
HandleLidSwitchDocked=ignore
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `sleep/hibernate-delay.conf`**
|
||||
|
||||
```bash
|
||||
mkdir -p sleep
|
||||
```
|
||||
|
||||
Write `sleep/hibernate-delay.conf` with this exact content:
|
||||
|
||||
```ini
|
||||
[Sleep]
|
||||
HibernateDelaySec=30min
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify both INI files**
|
||||
|
||||
```bash
|
||||
cat logind/lid.conf
|
||||
cat sleep/hibernate-delay.conf
|
||||
```
|
||||
|
||||
Expected: each file's contents print verbatim as above.
|
||||
|
||||
- [ ] **Step 4: Add deploy lines to `install.sh`**
|
||||
|
||||
Find the existing greetd deploy block in `install.sh`:
|
||||
|
||||
```bash
|
||||
echo "==> Deploying greetd config"
|
||||
sudo cp "$(pwd)/greetd/config.toml" /etc/greetd/config.toml
|
||||
sudo cp "$(pwd)/greetd/regreet.toml" /etc/greetd/regreet.toml
|
||||
```
|
||||
|
||||
Immediately *after* the second `sudo cp` line, insert:
|
||||
|
||||
```bash
|
||||
|
||||
echo "==> Deploying suspend/hibernate config"
|
||||
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 they don't exist (the `.conf.d` paths may not exist on a fresh install) and sets file mode to 644 — the right tool for system drop-in files.
|
||||
|
||||
- [ ] **Step 5: Bash-syntax-check install.sh**
|
||||
|
||||
```bash
|
||||
bash -n install.sh && echo "syntax ok"
|
||||
```
|
||||
|
||||
Expected: `syntax ok` and exit 0.
|
||||
|
||||
- [ ] **Step 6: Confirm the new lines appear in the right place**
|
||||
|
||||
```bash
|
||||
grep -A 4 "==> Deploying suspend" install.sh
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
echo "==> Deploying suspend/hibernate config"
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add logind/lid.conf sleep/hibernate-delay.conf install.sh
|
||||
git commit -m "logind+sleep: track lid hibernate config; deploy via install.sh"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: niri swayidle action
|
||||
|
||||
After this task, the 30-min idle path uses `systemctl suspend-then-hibernate` instead of `systemctl suspend`, matching the lid handler.
|
||||
|
||||
**Files:**
|
||||
- Modify: `niri/config.kdl` (line 39)
|
||||
|
||||
- [ ] **Step 1: Replace the swayidle action**
|
||||
|
||||
In `niri/config.kdl`, find line 39:
|
||||
|
||||
```kdl
|
||||
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"
|
||||
```
|
||||
|
||||
Replace `"systemctl suspend"` with `"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"
|
||||
```
|
||||
|
||||
Only one substring changes; nothing else on the line moves. The `before-sleep "gtklock" "-d"` clause stays — gtklock fires on suspend-then-hibernate's suspend phase too.
|
||||
|
||||
- [ ] **Step 2: Validate niri config**
|
||||
|
||||
```bash
|
||||
niri validate 2>&1 | tail -2
|
||||
```
|
||||
|
||||
Expected: a final line `INFO niri: config is valid`. Any "ERROR" or non-zero exit means a syntax issue.
|
||||
|
||||
- [ ] **Step 3: Confirm exactly one substitution happened**
|
||||
|
||||
```bash
|
||||
grep -c "systemctl suspend-then-hibernate" niri/config.kdl
|
||||
grep -c "\"systemctl suspend\"" niri/config.kdl
|
||||
```
|
||||
|
||||
Expected:
|
||||
- First count: `1` (the new value is present)
|
||||
- Second count: `0` (the old exact-string `"systemctl suspend"` is gone)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add niri/config.kdl
|
||||
git commit -m "niri: route swayidle 30-min timeout through suspend-then-hibernate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: README — One-time hibernation enablement subsection
|
||||
|
||||
After this task, the README documents the one-time bootstrap step so a reader setting up the system fresh knows to run the script.
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md` (append a subsection)
|
||||
|
||||
- [ ] **Step 1: Append the new subsection**
|
||||
|
||||
The README has exactly one top-level section, `## Setup` (line 22). The new subsection nests under it as `### One-time hibernation enablement`.
|
||||
|
||||
Append to the end of `README.md` (after the closing line of the existing Setup section):
|
||||
|
||||
````markdown
|
||||
|
||||
### One-time hibernation enablement
|
||||
|
||||
Hibernation requires a persistent swap file, kernel resume parameters, and a regenerated UKI. Run once, then reboot:
|
||||
|
||||
```bash
|
||||
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.
|
||||
````
|
||||
|
||||
(The outer fence above is four backticks because the block contains a triple-backtick code block — paste the inner content verbatim, no enclosing 4-backtick fence.)
|
||||
|
||||
- [ ] **Step 2: Verify the section was added**
|
||||
|
||||
```bash
|
||||
grep -A 6 "One-time hibernation enablement" README.md
|
||||
```
|
||||
|
||||
Expected: the new subsection prints, including the `sudo bash scripts/enable-hibernation.sh` line.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs: document one-time hibernation enablement"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification (controller, post-merge)
|
||||
|
||||
These can only be run on the live system, after merging and pushing all four task commits.
|
||||
|
||||
- [ ] **Bootstrap and reboot**
|
||||
|
||||
```bash
|
||||
sudo bash scripts/enable-hibernation.sh
|
||||
# Output should show steps 1–6 with each guard either firing or skipping.
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
- [ ] **After reboot, verify prerequisites**
|
||||
|
||||
```bash
|
||||
swapon --show # /swapfile, ≥14 GiB
|
||||
cat /proc/cmdline | grep -oE "resume=\S+ resume_offset=\S+" # both present
|
||||
grep "^HOOKS=" /etc/mkinitcpio.conf | grep resume # hook in array
|
||||
```
|
||||
|
||||
- [ ] **Re-deploy tracked configs**
|
||||
|
||||
```bash
|
||||
cd ~/Documents/dotfiles
|
||||
bash install.sh
|
||||
# Should print "==> Deploying suspend/hibernate config" and the two install lines.
|
||||
ls -la /etc/systemd/logind.conf.d/lid.conf /etc/systemd/sleep.conf.d/hibernate-delay.conf
|
||||
```
|
||||
|
||||
Expected: both files exist, mode 644, owned by root.
|
||||
|
||||
- [ ] **Reload logind so lid.conf takes effect**
|
||||
|
||||
```bash
|
||||
sudo systemctl restart systemd-logind # closes session — log back in after
|
||||
```
|
||||
|
||||
(Optional: a niri restart also picks up the new swayidle args. The change takes effect on next login regardless.)
|
||||
|
||||
- [ ] **Functional checks**
|
||||
|
||||
1. `systemctl hibernate` — laptop powers off completely; press power → resumes to gtklock in ~20–30 s.
|
||||
2. Close lid on battery — laptop suspends instantly; reopen within 30 min → instant resume; reopen after 30+ min → ~30 s resume from hibernation.
|
||||
3. Close lid on AC — same as battery (no longer just locks).
|
||||
4. Leave laptop idle 30 min — `journalctl -u systemd-suspend-then-hibernate.service` shows the action firing.
|
||||
Reference in New Issue
Block a user