fix(docking): retry the undock; a lost keyword left the panel black
Some checks failed
Check / eval (push) Has been cancelled
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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user