fix(docking): the undock keyword is inert, not raced; escalate to reload
All checks were successful
Check / eval (push) Successful in 3m10s

Round 7 FAILED: 5-6 consecutive unplugs on the dev box, the panel never came
back. Round 6's retry could not have worked, because round 6's diagnosis was
wrong — and so was the "phantom re-add" theory I brought to this session.

With ZERO enabled outputs — panel disabled by the dock, external gone —
Hyprland 0.55.4 accepts `hyprctl keyword monitor eDP-1,preferred,auto,1`,
prints `ok`, exits 0, and never flushes it: the rule waits for a DRM event
that is not coming. It is inert, not raced. Retrying it 25x/poll for 6 polls
bought 30s of black screen and nothing else.

What made this survive two rounds is worth more than the fix: every
`transition=undock result=ok` in the round-6 journal was Bernardo plugging
the cable back in because the screen was black. His hotplug flushed the
queued rule and the next poll took the credit. Success was indistinguishable
from the user working around the failure, so the logs confirmed whichever
story we brought to them. Ten minutes of probing on hardware killed both.

Probed live 2026-07-14 (dev box, cable out): keyword inert across 4s and 5s
in two runs; `dispatch forcerendererreload` inert too; only `hyprctl reload`
escapes — 99ms and 289ms. So: issue the keyword (enough whenever another
output is still enabled, e.g. the menu's Dock mode), and escalate to reload
only when `monitors` proves it inert. After a reload, re-assert the rule so a
config that parks the panel off cannot undo the undock, and restore per-device
keyboard layouts — a reload re-reads the config and drops the runtime
`device[<name>]:kb_layout` an external board depends on. The menu's `enable`
now proves itself against `monitors` too, instead of cheering for an exit code.

Verified V3 (partial): the fixed transition driven through a real unplug —
panel on, workspaces 1-3 home, 1.8s, `keyword=inert escalate=reload` ->
`enable=via-reload` -> `result=ok`. V2: nix flake check --no-build; docking-ux
+ dock-audio; monitor-fallback.nix; shellcheck clean on display-transition.

NOT proven, and queued as HARDWARE-QUEUE round 8: the watcher-driven path
(exec-once + baked store path = relogin required, the round-5 trap) and
repetition. The keyboard-restore path is untested — the dev box's keyboard
hangs off the dock's hub, so it leaves with the cable; round 8 adds a check
with a keyboard in the laptop. The VM harness still cannot reach any of this:
QEMU aborts Hyprland if the last active output is deleted while the DRM output
is disabled, which is the same zero-output degeneracy from the other side.

New BACKLOG #114 (PROPOSED): tuigreet ignores per-device keyboard layouts —
a VT has one keymap, so the greeter cannot honour `device[]:kb_layout`.
Bernardo hit it logging out while docked; not a regression, a design gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 12:28:06 +01:00
parent 28be6779a7
commit bec826baf0
5 changed files with 172 additions and 56 deletions

View File

@@ -156,6 +156,32 @@ let
done
printf '%s' "$commands"
}
# `monitors` (enabled-only) listing the output is the ONLY proof it is
# really on: every path here issues keywords hyprctl answers "ok" to
# without acting. $1 = output, $2 = polls of 0.2s.
output_enabled() {
tries=0
while [ "$tries" -lt "$2" ]; do
hyprctl monitors -j 2>/dev/null \
| jq -e --arg m "$1" 'any(.[]; .name == $m)' >/dev/null && return 0
tries=$((tries+1)); sleep 0.2
done
return 1
}
# `hyprctl reload` re-reads the config, and every runtime keyword dies
# with it including the `device[<name>]:kb_layout` the keyboard watcher
# applies to an external board. The remembered choices live in the
# in-flake state, so `restore` is authoritative and cheap; without this a
# reload would silently drop an external keyboard back to the session
# layout mid-undock.
restore_keyboards() {
hyprctl devices -j 2>/dev/null | jq -r '.keyboards[].name' 2>/dev/null \
| while IFS= read -r kb; do
[ -n "$kb" ] || continue
${keyboardTool}/bin/nomarchy-keyboard-layout restore "$kb" \
>/dev/null 2>&1 || true
done
}
case "''${1:-}" in
dock)
@@ -181,22 +207,39 @@ let
valid_output "$internal" || { echo "invalid monitor name" >&2; exit 64; }
# Safety-critical ordering: never remove/move away from an external
# before the internal panel is active and can receive workspaces.
# The enable races the departing external's teardown: a keyword that
# 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=
tries=0
while [ "$tries" -lt 25 ]; do
#
# With ZERO enabled outputs an abrupt undock, the panel already
# disabled by the dock Hyprland 0.55.4 accepts `keyword monitor`
# (prints "ok", exits 0) and then never flushes it: the rule is held
# until some DRM event arrives, and the panel stays dark. This is
# deterministic, not a race. Probed on hardware 2026-07-14: the
# keyword was inert across 4s and 5s windows in two separate runs,
# `dispatch forcerendererreload` did not flush it either, and only
# `hyprctl reload` did in 99ms and 289ms, with the cable out.
#
# Round 6 read the same symptom as a teardown race and re-issued the
# keyword 25 times a poll. Retrying an inert command cannot work: it
# bought 30s of black screen and every `result=ok` in that journal was
# really the user plugging the cable back in, whose hotplug flushed
# the queued rule and let the next poll take the credit.
#
# So issue the keyword once enough whenever another output is still
# enabled, e.g. the menu's Dock mode toggle and escalate to the
# reload hammer only when it proves inert.
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
if ! output_enabled "$internal" 5; then
log "transition=undock internal=$internal keyword=inert escalate=reload"
hyprctl reload >/dev/null 2>&1
output_enabled "$internal" 25 \
|| { log "transition=undock internal=$internal result=enable-failed"; exit 1; }
# The config's monitor rules own the panel now. Re-assert the
# runtime rule which flushes normally, an output being enabled
# again so a config that parks the panel off cannot undo the
# undock, and give the keyboards back what the reload took.
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
if hyprctl monitors -j 2>/dev/null \
| jq -e --arg m "$internal" 'any(.[]; .name == $m)' >/dev/null; then
ready=1; break
fi
tries=$((tries+1)); sleep 0.2
done
[ -n "$ready" ] \
|| { log "transition=undock internal=$internal result=enable-timeout"; exit 1; }
restore_keyboards
log "transition=undock internal=$internal enable=via-reload"
fi
moves=$(all_workspaces | batch_moves "$internal")
hyprctl --batch "$moves dispatch focusmonitor $internal" >/dev/null 2>&1 \
|| { log "transition=undock internal=$internal result=move-failed"; exit 1; }
@@ -204,7 +247,12 @@ let
enable)
internal="''${2:-}"
valid_output "$internal" || exit 64
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 ;;
# Reachable only from the menu, i.e. with a screen already on, so the
# keyword flushes and no reload escalation is needed but still make
# `monitors` the proof, or the menu's "back on" notification cheers
# for a keyword hyprctl merely said "ok" to.
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
output_enabled "$internal" 10 ;;
*)
echo "usage: nomarchy-display-transition [dock <internal> <external> [external-rule]|undock <internal>|enable <internal>]" >&2
exit 64 ;;
@@ -533,8 +581,8 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
# awaiting_lid_open marks an undock already completed for this
# 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.
# driven by the invariant below, so a single lost or failed attempt
# can never be what costs the panel.
if [ -n "$gone" ] && [ -z "$ext" ] && [ -n "$internal" ] \
&& [ -z "$awaiting_lid_open" ]; then
undock_pending=1
@@ -552,9 +600,11 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
#
# 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.
# one-shot fired off the removal event, which a dropped IPC event or a
# transient hyprctl failure would then turn into a black laptop. (The
# transition itself no longer *expects* to fail: what used to fail
# every time was an inert keyword, fixed at the source above. This
# stays as the backstop it was always meant to be.)
if [ -n "$undock_pending" ] && [ -z "$ext" ] && [ -n "$internal" ]; then
if "$TRANSITION" undock "$internal"; then
undock_pending=