fix(docking): retry the undock; a lost keyword left the panel black
Some checks failed
Check / eval (push) Has been cancelled

Round 6 passed on the plug side but unplugging the cable left the laptop
panel black until it was re-docked. The journal named it: across three
undocks, two logged result=enable-timeout and one result=ok — a race, not
a logic error.

Two halves, both needed:

  - `hyprctl keyword monitor eDP-1,preferred,auto,1` is accepted (exit 0)
    while Hyprland is still tearing down the departing external, then
    silently dropped. Re-issue it on every poll for 5s and keep `monitors`
    listing the panel — not hyprctl's exit code — as the only proof.

  - The watcher fired the undock once off the removal event and marked the
    departure handled regardless (`|| true` + awaiting_lid_open=1). Since
    known_outputs had already moved, the set-changed guard never reopened
    and nothing retried, so one lost keyword cost the panel until re-dock.
    The undock is now a queued invariant driven on the 1s tick, bounded at
    6 attempts so a genuinely unenableable panel can't pin the inhibitor.

The VM harness cannot reach this race: QEMU's headless backend aborts
Hyprland if the last active output is deleted while the DRM output is
disabled, so the test must undock before removing DP-1 and never collides
the two. Noted there, and V3 queued as round 7 — unplug 5+ times, since
the old failure only hit ~2 in 3.

Verified: V2 — nix flake check --no-build; monitor-fallback.nix exit 0 in
57s; shellcheck clean. The fix itself is V3 pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 11:35:12 +01:00
parent f625c0eaf4
commit 44ecc9094e
4 changed files with 110 additions and 9 deletions

View File

@@ -32,7 +32,40 @@ the **T14s** (webcam case).
nomarchy-battery-charge-limit.service` stays false, `Result=success`, nomarchy-battery-charge-limit.service` stays false, `Result=success`,
BAT0's `charge_control_end_threshold` still matches the limit, and the BAT0's `charge_control_end_threshold` still matches the limit, and the
journal has no new `start-limit-hit`/`Failed with result` lines. journal has no new `start-limit-hit`/`Failed with result` lines.
- [ ] **Docking recovery round 5 (dock/keyboard/audio hotplug, 2026-07-14)** - [ ] **Docking recovery round 7 (undock panel restore, 2026-07-14)**
round 6 (AMD dev box, Bernardo) **passed on the plug side**: the dock
docked by itself, audio went straight to the monitor, and the wallpaper
painted on the external. One count failed: **unplugging the cable left
the laptop panel black**, recoverable only by re-docking. The journal
named the cause exactly — across three undocks, two logged
`transition=undock internal=eDP-1 result=enable-timeout` and one
`result=ok`, i.e. an intermittent race, not a logic error. Both halves
are fixed in the commit carrying this entry: `hyprctl keyword monitor
eDP-1,preferred,auto,1` is *accepted* (exit 0) while Hyprland is still
tearing down the departing external and then silently dropped, so the
transition now re-issues it on every poll for 5s rather than trusting
one shot; and the watcher no longer treats a failed undock as final —
the departure is queued and retried on the 1s tick (up to 6 attempts)
instead of being marked handled with `|| true`, which is what turned one
lost keyword into a panel dark until re-dock. **The VM harness cannot
reach this** (`tools/monitor-fallback.nix`: QEMU's headless backend
aborts Hyprland if the last active output is deleted while the DRM
output is disabled, so the test must undock *before* removing DP-1 and
never collides the two) — hardware is the only place it can be proven.
**Re-check:** relogin (mandatory — see below), dock, then unplug the
cable **at least five times**, since the old failure only hit ~2 of 3.
**Pass:** the panel comes back every single time with workspaces intact,
and `journalctl --user -t nomarchy-display-transition -t
nomarchy-display-watch --since '-10 min'` shows a
`transition=undock result=ok attempts=N` for each undock and **never**
`result=gave-up`. `attempts=2` or more is the retry doing its job and is
a pass; `gave-up` means the panel could not be enabled at all and is a
different fault — capture the artifacts listed in round 4 before
changing state.
- [ ] **Docking recovery round 5/6 (keyboard + headphone-jack counts,
2026-07-14)** — round 6 confirmed the dock/audio/wallpaper counts
below; the **keyboard-prompts-exactly-once** and **headphone
plug/unplug** counts were not reported either way and still need a run.
— round 4 was run on the AMD dev box and **failed on four counts** — round 4 was run on the AMD dev box and **failed on four counts**
(Bernardo): no wallpaper on the external monitor, no automatic dock (Bernardo): no wallpaper on the external monitor, no automatic dock
mode (System Display had to be used by hand), audio staying on the mode (System Display had to be used by hand), audio staying on the

View File

@@ -19,6 +19,34 @@ Template:
--- ---
## 2026-07-14 — docking round 6: undock left the laptop panel black (this commit)
- **Task:** HARDWARE-QUEUE docking round 6 result (Bernardo, AMD dev box).
Plug side all passed (auto-dock, audio to the monitor, wallpaper on the
external); unplugging left the panel black until re-docked.
- **Did:** Bernardo's journal was decisive — 3 undocks, 2×
`result=enable-timeout` + 1× `result=ok`, so an intermittent race. Two
fixes in modules/home/hyprland.nix: (1) the undock `hyprctl keyword monitor
eDP-1,preferred,auto,1` exits 0 but is dropped when it lands mid-teardown
of the departing external — now re-issued every poll for 5s, with
`monitors` listing the panel as the only proof; (2) the watcher marked the
departure handled via `"$TRANSITION" undock … || true` + `awaiting_lid_open=1`
and `known_outputs` had already moved, so nothing ever retried — one lost
keyword = a dark panel. The undock is now a queued invariant driven on the
1s tick (6 attempts, then `result=gave-up`).
- **Verified:** V2 — `nix flake check --no-build` all checks passed;
`nix build --impure -f tools/monitor-fallback.nix` exit 0 in 57s (reaches
the final `lid-open transition` checkpoint); shellcheck clean on both
generated scripts (the lone SC2125 is pre-existing + intentional).
**Honest limit:** the harness CANNOT reach this race — QEMU's headless
backend aborts Hyprland if the last active output is deleted while the DRM
output is disabled, so it must undock before removing DP-1 and never
collides the two. The fix itself is **V3 pending**; comment added at
tools/monitor-fallback.nix so a reader doesn't mistake a pass for coverage.
- **Pending:** V3 → HARDWARE-QUEUE round 7 (unplug ≥5×; old failure was only
~2 in 3). Round 5/6's keyboard-prompt + headphone-jack counts were never
reported either way — split into their own entry, still unverified.
- **Next suggestion:** wait on round 7 before touching the dock path again.
## 2026-07-13 — #99 live-seed git-init; theme-switch failure filed as #113 (this commit) ## 2026-07-13 — #99 live-seed git-init; theme-switch failure filed as #113 (this commit)
- **Task:** BACKLOG #99 — false "theme-state.json NOT git-tracked" doctor/ - **Task:** BACKLOG #99 — false "theme-state.json NOT git-tracked" doctor/
Waybar warning in the live session; resolve the held question from Fable. Waybar warning in the live session; resolve the held question from Fable.

View File

@@ -181,16 +181,19 @@ let
valid_output "$internal" || { echo "invalid monitor name" >&2; exit 64; } valid_output "$internal" || { echo "invalid monitor name" >&2; exit 64; }
# Safety-critical ordering: never remove/move away from an external # Safety-critical ordering: never remove/move away from an external
# before the internal panel is active and can receive workspaces. # before the internal panel is active and can receive workspaces.
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 \ # The enable races the departing external's teardown: a keyword that
|| { log "transition=undock internal=$internal result=enable-failed"; exit 1; } # lands mid-modeset is accepted (hyprctl exits 0) and then quietly
# dropped, so re-issue it every poll instead of trusting one shot.
# `monitors` listing the panel not hyprctl's exit code is proof.
ready= ready=
tries=0 tries=0
while [ "$tries" -lt 20 ]; do while [ "$tries" -lt 25 ]; do
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
if hyprctl monitors -j 2>/dev/null \ if hyprctl monitors -j 2>/dev/null \
| jq -e --arg m "$internal" 'any(.[]; .name == $m)' >/dev/null; then | jq -e --arg m "$internal" 'any(.[]; .name == $m)' >/dev/null; then
ready=1; break ready=1; break
fi fi
tries=$((tries+1)); sleep 0.1 tries=$((tries+1)); sleep 0.2
done done
[ -n "$ready" ] \ [ -n "$ready" ] \
|| { log "transition=undock internal=$internal result=enable-timeout"; exit 1; } || { log "transition=undock internal=$internal result=enable-timeout"; exit 1; }
@@ -324,6 +327,8 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
inhibitor_pid= inhibitor_pid=
inhibitor_file="$rt/nomarchy-dock-lid-inhibitor.pid" inhibitor_file="$rt/nomarchy-dock-lid-inhibitor.pid"
awaiting_lid_open= awaiting_lid_open=
undock_pending=
undock_tries=0
log() { "$LOGGER" -t nomarchy-display-watch -- "$*"; } log() { "$LOGGER" -t nomarchy-display-watch -- "$*"; }
internal_output() { internal_output() {
if [ -n "''${NOMARCHY_INTERNAL_OUTPUT:-}" ]; then if [ -n "''${NOMARCHY_INTERNAL_OUTPUT:-}" ]; then
@@ -513,6 +518,9 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
known_outputs=$cur known_outputs=$cur
if [ -n "$added_ext" ]; then if [ -n "$added_ext" ]; then
# A new external supersedes any undock still queued for an older
# departure: the panel is about to be disabled again on purpose.
undock_pending=
[ -z "$internal" ] || acquire_inhibitor [ -z "$internal" ] || acquire_inhibitor
# An external output that was not there a moment ago is an # An external output that was not there a moment ago is an
# unambiguous physical plug whether or not we saw the IPC event, # unambiguous physical plug whether or not we saw the IPC event,
@@ -522,13 +530,16 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
paint_outputs & paint_outputs &
log "outputs-changed added=$added_ext audio-reprobe=scheduled" log "outputs-changed added=$added_ext audio-reprobe=scheduled"
fi fi
# awaiting_lid_open marks an undock already performed for this # awaiting_lid_open marks an undock already completed for this
# departure the guard is what keeps event + tick from doubling up. # departure the guard is what keeps event + tick from doubling up.
# The attempt itself is deliberately NOT made here: it is queued and
# driven by the invariant below, because the first try races the
# external's teardown and losing it must not cost the panel.
if [ -n "$gone" ] && [ -z "$ext" ] && [ -n "$internal" ] \ if [ -n "$gone" ] && [ -z "$ext" ] && [ -n "$internal" ] \
&& [ -z "$awaiting_lid_open" ]; then && [ -z "$awaiting_lid_open" ]; then
"$TRANSITION" undock "$internal" || true undock_pending=1
awaiting_lid_open=1 undock_tries=0
log "outputs-changed removed=$gone transition=undock" log "outputs-changed removed=$gone transition=undock-queued"
fi fi
check_monitors check_monitors
if [ -n "$added_ext" ] \ if [ -n "$added_ext" ] \
@@ -538,6 +549,28 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
fi fi
# Safety invariants, re-checked regardless of whether the set moved. # Safety invariants, re-checked regardless of whether the set moved.
#
# A dark panel is the worst outcome this watcher can produce, so the
# undock is an invariant driven to completion on the tick not a
# one-shot fired off the removal event. On hardware the first attempt
# loses the race with the external's teardown often enough that
# treating it as final left the laptop black until it was re-docked.
if [ -n "$undock_pending" ] && [ -z "$ext" ] && [ -n "$internal" ]; then
if "$TRANSITION" undock "$internal"; then
undock_pending=
awaiting_lid_open=1
log "transition=undock result=ok attempts=$((undock_tries+1))"
else
undock_tries=$((undock_tries+1))
# Bounded: a panel that cannot be enabled at all is a different
# fault, and retrying forever would pin the lid inhibitor with it.
if [ "$undock_tries" -ge 6 ]; then
undock_pending=
awaiting_lid_open=1
log "transition=undock result=gave-up attempts=$undock_tries"
fi
fi
fi
if [ -n "$internal" ] && [ -n "$ext" ]; then if [ -n "$internal" ] && [ -n "$ext" ]; then
if [ -z "$inhibitor_pid" ] || ! kill -0 "$inhibitor_pid" 2>/dev/null; then if [ -z "$inhibitor_pid" ] || ! kill -0 "$inhibitor_pid" 2>/dev/null; then
acquire_inhibitor || true acquire_inhibitor || true

View File

@@ -130,6 +130,13 @@ pkgs.testers.runNixOSTest {
# monitorremoved event still proves that the watcher retains the inhibitor # monitorremoved event still proves that the watcher retains the inhibitor
# until lid-open, without mistaking a backend limitation for product # until lid-open, without mistaking a backend limitation for product
# behavior. Physical cable-yank timing remains the bounded V3 check. # behavior. Physical cable-yank timing remains the bounded V3 check.
#
# Undocking a quiescent output is therefore all this harness can assert
# and that gap is exactly what shipped a black panel in round 6: on real
# hardware the enable collides with the departing external's teardown and
# is silently dropped ~2 times in 3. The keyword re-issue in the
# transition and the tick-driven retry in the watcher are what cover it;
# neither is exercised below, so do not read a pass here as proof of them.
user(f"{transition} undock Virtual-1") user(f"{transition} undock Virtual-1")
machine.wait_until_succeeds( machine.wait_until_succeeds(
hy + "-j monitors' | jq -e 'any(.[]; .name == \"Virtual-1\")'", timeout=20 hy + "-j monitors' | jq -e 'any(.[]; .name == \"Virtual-1\")'", timeout=20