fix(idle): #127 — input wakes the screen compositor-side; revert the clamshell skip
All checks were successful
Check / eval (push) Successful in 3m33s

The idle brick was never DPMS. Hyprland ships misc:key_press_enables_dpms
and misc:mouse_move_enables_dpms OFF, so hypridle's on-resume was the ONLY
caller of `dpms on` in the session — an idle daemon with the display held
hostage behind it. hypridle then deadlocks on an unfixed upstream bug
(hyprwm/hypridle#171, "Disconnected from pollfd id 1", open since 2025-09,
absent from 0.1.7 and main) and hangs WITHOUT exiting, so Restart=always
never fires and systemd still reports it active (running). Blank + deadlock
= a black seat no keypress can escape. That was the brick: two mundane bugs,
not one exotic DRM state. 19 disconnects in 11 days on the dev box, incl.
the incident day; the compositor is healthy through all of them.

Both options on, so the wake lives in the compositor where no daemon can
lose it — a dead hypridle now costs auto-lock, not the machine. The #127
clamshell DPMS-off skip (060bf52) is therefore reverted: idle blanks again
in every dock/lid state, and the 27" panels stop being held lit all night
under a static lock screen. #135 (an option to opt back in) is retired
unbuilt — there is nothing left to opt into.

TEST B's first run was invalid and nearly bought the wrong answer: it ran
`hyprctl dispatch dpms off` by hand, which no wake path watches, so "input
did not wake it" was guaranteed on any hardware. The undocked control
returning the SAME result — where the theory demanded a difference — is what
exposed it. Driven through hypridle instead, input wakes it in all three
configurations, including clamshell-docked on the sole live output.

Filed from the diagnosis: #146 (hypridle dies silently, systemd calls it
healthy — the surviving bug) and #147 (a re-login leaves session units dead;
graphical-session.target never re-enters, which is why Waybar already runs
from exec-once).

V3 on the incident hardware (AMD dev box, clamshell): hypridle deliberately
stopped, `dpms off` -> keypress woke it in 6s; the identical test before the
options stayed black the full 45s. V1: nix flake check, checks.option-docs,
checks.clamshell-logind, checks.docking-ux, checks.display-profiles; read the
emitted hyprland.conf/hypridle.conf rather than the source.

V3 pending: the fix is proven via runtime `hyprctl keyword`, not yet from our
own Nix — HARDWARE-QUEUE re-checks it after nomarchy-home + relogin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 18:48:22 +01:00
parent c32b51897e
commit a2151f424b
7 changed files with 187 additions and 238 deletions

View File

@@ -31,163 +31,8 @@ post-boot sessions). Preserve separation: the installer bake failure and the
flake pin are different root causes even when they show up on the same machine.
(Terminal / Ghostty-on-Acer → shipped as Kitty-only, #95.)
### 127. `[blocked:hw]` Docked idle: black screens, no wake, undock does not restore panel
Bernardo 2026-07-15 (AMD dev box, docked): left the laptop docked; after a
few minutes of idle it appeared to "sleep". Then:
1. External keyboard/mouse did **not** wake any display.
2. Unplugging the external monitor did **not** turn the laptop panel back
on (dock mode has eDP disabled — undock *should* re-enable it).
3. The machine was still alive: Caps Lock LED toggled on the keyboard.
This also killed a long agent session (looked like a "crash" from the
outside). Separate from awake undock recovery (HARDWARE-QUEUE round 8):
the failure is **idle while docked**, then no path back to a usable panel.
**Likely shape (code-side, unconfirmed on the incident):** on AC, hypridle
does **not** call `systemctl suspend` (`onAc || suspend` — only battery
suspends at 15 min). Idle path is lock @5 min + `dpms off` @10 min
(`modules/home/idle.nix`). Caps Lock working fits **DPMS/lock blackout**
better than deep S3. Dock mode has already disabled eDP; recovery then
needs either (a) input → hypridle `on-resume` / `dpms on` on the external,
or (b) undock → `nomarchy-display-transition undock` re-enabling the
panel. Both failed. `after_sleep_cmd` only does `hyprctl dispatch dpms on`
and never re-enables a disabled internal.
**Progress 2026-07-15:**
- Recovery tooling: undock ends with `dpms on`; `nomarchy-display-wake` /
`nomarchy-display-dump` (SUPER+SHIFT+D; TTY if it works).
- **Mitigation (shipped):** do **not** DPMS-off when **clamshell** — if any
laptop internal (eDP/LVDS/DSI) exists in `monitors all` but is not
enabled, the 10 min listener skips blanking. Lock at 5 min still runs;
undocked laptops still DPMS-off as before. Bernardo: Ctrl+Alt+F3 did
not yield a usable TTY on the original brick, so prevention > dump path.
- **Scope settled (#136, 2026-07-16):** the gate is *clamshell*, not
"docked" — and Bernardo confirms the incident was exactly that, **laptop
closed**. So the code matched the evidence and only the wording
overclaimed; that is fixed (idle.nix, ROADMAP, and the journal line, now
`dpms-off skipped: clamshell`). Docked with the lid **open** still blanks
and is untested — not exempted, not claimed safe.
- **Revisit later (LATER):** intentional DPMS-when-clamshell once wake is
proven trustworthy (monitor power-save without a brick). Tracked as
**#135** (state key + the surface to delete this by), downstream of the
hardware test below.
- **Caveat the test must settle:** the mitigation is a cure for an
**unconfirmed cause**. "Blanking the only live output caused the brick"
is inference from one incident, shipped at V1. If DPMS-off is not the
trigger, we are paying a permanent behaviour cost (docked externals never
sleep) and the brick is still live — so confirming the cause outranks
#135/#136.
- **Symptom 2 is SOLVED, and it was not DPMS (ff5017e, 2026-07-16).** Bernardo
reproduced "unplugging did not turn the laptop panel back on" live, minutes
after TEST A — with **no blanking involved at all** (the mitigation had kept
the external lit). Cause: `awaiting_lid_open` is set by every successful
undock and cleared only when the external is gone **and** the lid is open, so
a clamshell undock **latches** it; a re-dock clears its sibling
`undock_pending` but not the latch, and the next departure is then silently
dropped by the `[ -z "$awaiting_lid_open" ]` guard. His journal shows the
first undock logging *nothing*, a release the instant he lifted the lid, then
a clean undock. Replaying the sequence with the lid never opened: 1 of 3
undocks ran; fixed, 3 of 3. **What this does to the theory:** the original
brick may simply have been an ordinary blank plus a recovery that could not
run — two mundane bugs, not one exotic DRM state. TEST B is now the only
thing standing between that hypothesis and the truth, and it is a much safer
test with the latch fixed (an undock is a real escape hatch again).
**Pass (current mitigation):** clamshell-docked on AC, idle past 10+ min →
external stays lit under hyprlock (journal: `dpms-off skipped: clamshell`);
undocked still blanks at 10 min and wakes on input. Full original pass (wake
after DPMS-while-clamshell) deferred to the LATER revisit.
---
### The two tests, exactly (2026-07-16)
**There is no SSH lifeline on this box** (`sshd` inactive), which is why test B
self-recovers instead of relying on one. If you would rather have the net, turn
`services.openssh.enable = true` on for the day and keep a phone SSH client
open — then a brick is a nuisance, not a reboot.
**Test A — does the mitigation hold? (safe, ~15 min, unattended)**
1. Docked, **lid closed**, on AC. Leave it completely alone for 12 minutes.
2. Expect: at 5 min the external shows hyprlock; at 10 min it **stays lit**.
3. Then: `journalctl --user -b -t nomarchy-idle --since '-15 min'`
→ one `dpms-off skipped: clamshell (internal eDP-1 present but off)`.
4. Now open the lid / undock, idle 11 min → the panel **should** blank, and
wake on a keypress. That half must still work; the mitigation is not
supposed to have disabled blanking everywhere.
**Test B — was DPMS-off actually the trigger? (the decisive one; bounded)**
This is the test that outranks everything else here, because the mitigation is
a cure for an *inferred* cause. Docked, **lid closed**, from a terminal on the
external:
```sh
( sleep 45
hyprctl dispatch dpms on
hyprctl reload # re-enables every output (the #142 hammer)
) >/tmp/dpms-test.log 2>&1 &
hyprctl dispatch dpms off
```
The screen goes black. Then, in order:
1. **Press keys / move the mouse** in the first ~40s. Does it come back?
(In the incident it did not — this is the exact step that failed.)
2. **Wait for the 45s auto-recovery.** Does the external return by itself?
3. If it is still dark, **open the lid** — the `reload` should have re-enabled
eDP even if the external stayed dead.
4. Afterwards:
`journalctl --user -b -t nomarchy-idle -t nomarchy-display-watch --since '-5 min'`
**What each outcome means — all three are useful:**
- **Input wakes it** → DPMS-off is **not** the trigger. The mitigation is a
permanent behaviour cost buying nothing, the brick is still live and
un-diagnosed → revert the skip, **#135 never exists**, and hunt the real
cause (hyprlock? hypridle's own resume path? the dock's DP link?).
- **Dark until the 45s recovery, then fine** → the compositor was alive and
`dpms on` works, so blanking-while-clamshell *is* the trigger and the
mitigation is right → **#135 becomes the way to opt back in**.
- **Dark even after recovery** (lid needed) → the original brick, reproduced
**deterministically and on demand**. That is the best outcome: it turns a
once-off incident into something bisectable (try it without hyprlock, with
the lid open, on the internal only …).
Note the deliberate omission: this runs `dpms off` **by hand** rather than
waiting for hypridle, precisely because the shipped mitigation would otherwise
skip it. Same command, same state, no rebuild.
**Fallback if the cause resists (Bernardo 2026-07-16):** diagnosing stays the
preference — DPMS when docked is the outcome we want. But the mitigation as
shipped holds a near-static lock screen on the externals indefinitely, so if
the cause cannot be pinned down, a **screensaver while docked** is the
consolation prize: it protects the panels (burn-in — the same cost #135
names) without re-entering the blanking path that bricked the session. Not a
new item: it is the "if diagnosis fails" branch of this one, and #135's state
key is where it would land.
## NEXT
### 135. Give the #127 DPMS mitigation a state key (and a way back)
The shipped mitigation is hardcoded policy: docked (clamshell) externals now
**never** blank, and hyprlock has no timeout of its own, so they stay lit
indefinitely. Two real costs land on the user — a couple of 27" panels at
~60100W all night, and **OLED burn-in** from a near-static lock screen held
for hours. Today there is no way to opt back in, which also breaks the
in-flake-state rule that settings are menu-writable.
Add `settings.idle.dpmsWhenDocked` (default **false** = today's safe
behaviour), read by `modules/home/idle.nix`'s `dpmsOff` gate, with a
Preferences row. The real point is not the option: it gives the mitigation a
**named surface to delete** once wake is proven, instead of an unowned LATER
note that quietly becomes the product. Whoever removes the mitigation removes
the key in the same commit.
**Order:** after the #127 hardware test. If DPMS-off turns out not to be the
trigger, the mitigation is reverted wholesale and this key never exists —
building it first risks shipping an option for a policy we withdraw.
### 143. Rofi keeps the selected *row number* when the search changes, so the highlight lands on an unrelated entry
Bernardo 2026-07-16, and his repro is exact: open the app menu, type `ca`, move
@@ -246,14 +91,6 @@ the next lock bump, that is fine — nothing here rots meanwhile.
## LATER
- **DPMS when docked (revisit #127):** today we **skip** display blanking
while any laptop internal is present-but-disabled (dock/clamshell), so
idle only locks. That avoids blanking the sole external and the brick
where wake/TTY failed (2026-07-15). Revisit when we want monitor
power-save on dock again: re-enable DPMS-off for that mode only if
`nomarchy-display-wake` (or better) reliably restores the external and
undock-while-black restores eDP on the AMD dock setup — without
depending on SSH. Until then, leave the skip in `modules/home/idle.nix`.
- **Wallpapers artifact split** (ROADMAP § Faster switches — decided,
deferred): pinned `Nomarchy-wallpapers` input so a state write stops
re-copying 86 MB. Follow-on: pre-built theme variants if switches are
@@ -299,6 +136,64 @@ high-ROI, etc.) live in the journal + ROADMAP — not here.*
### Product / day-2
### 146. hypridle dies silently and systemd calls it healthy
Fell out of the #127 diagnosis (2026-07-16, ROADMAP § "#127 solved"), and it is
the bug that was actually behind the brick — the DPMS gate we just reverted was
only ever a symptom shield. hypridle hits an unfixed upstream deadlock
(hyprwm/hypridle#171, "Disconnected from pollfd id 1" — open since 2025-09, in
neither 0.1.7 nor main; upstream's own reporter saw 10-hour hangs) and then
**hangs without exiting**, so `Restart=always` never fires and `systemctl
--user is-active` still says `active (running)`. On the dev box: **19 times in
11 days**, including the incident day. Jul 14 is the clean example — locking
normally at 13:02, `CRITICAL` at 13:08:17, then eleven hours of silence, with
D-Bus receive queues visibly backing up (48 KB unread) because nothing was
reading them. The compositor is *fine* through all of it (no other session unit
dies in the same second), so this is not the compositor's death taking it down.
Now that input wakes the screen compositor-side this is no longer brick-class,
but it is not cosmetic either: a silently dead hypridle means **no auto-lock**
(Bernardo sat unlocked for 25 min on 2026-07-16 without knowing), no blank, no
idle suspend. Cost: systemd can't see it — `WatchdogSec` needs `sd_notify`
hypridle doesn't implement, and the process never exits — so detection has to be
external: a guard watching for the `CRITICAL` line, or a liveness probe on its
Wayland fd, then `systemctl --user restart hypridle`. **Sequence it after #147**:
today's disconnect was the compositor dying, and restarting hypridle into a dead
compositor just fail-loops into `start-limit-hit` exactly as cliphist does. The
trigger is still unknown — *not* dock/undock (no display-watch activity in the
3 min before any of them) and *not* a D-Bus quota disconnect (no broker errors).
Worth a reproducer + an upstream comment on #171: it has sat ten months on
vaxerski's "that will cause a segfault no?", and we have better data than the
reporter did.
### 147. A re-login leaves the session's user units dead, and nothing brings them back
Bernardo, 2026-07-16: after re-logging in, Waybar took 5+ seconds and
nomarchy-doctor reported cliphist, network-manager-applet and udiskie failing.
Root cause is the session lifecycle, not those apps. Hyprland exited at
17:25:39 and the replacement came up at **17:25:52** — a 13-second gap — but
`graphical-session.target` never re-entered (its `ActiveEnterTimestamp` still
read 08:03:32, from the *morning's* session). So: units with a restart policy
burn their start limit inside that gap against a Wayland socket that does not
exist yet (cliphist's journal is explicit — `wl-paste: Failed to connect to a
Wayland server`, 5 restarts in one second, `start-limit-hit` at 17:25:40,
twelve seconds before the new compositor existed); units without one
(nm-applet, udiskie) simply die once and stay dead forever. Nothing re-enters
the target, so nothing recovers.
We have already paid for this once without naming it: `modules/home/hyprland.nix`
launches Waybar from `exec-once` rather than a unit, and the comment there says
why — "bound to graphical-session.target the unit raced Hyprland's IPC on
relogin". That is this bug, worked around for one service; the 5-second Waybar
start is the workaround being slow instead of failing. Fix is the standard
wiring the repo never adopted: the compositor imports `WAYLAND_DISPLAY` +
`HYPRLAND_INSTANCE_SIGNATURE` into the user manager and starts/stops a
`hyprland-session.target` on entry/exit (Home Manager's
`wayland.windowManager.hyprland.systemd.enable`, or uwsm). Cost: touches how
every session service is bound, so it wants a VM pass and a real relogin on
hardware — but it would let Waybar go back to being a unit and unblock #144's
guard.
### 120. A netinstall ISO, next to the fat offline one
**Deferred to PROPOSED 2026-07-16 (Bernardo): not now.** Nothing below is