From bec826baf0f2b3535b027e815bf08789cc0da6e6 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Tue, 14 Jul 2026 12:28:06 +0100 Subject: [PATCH] fix(docking): the undock keyword is inert, not raced; escalate to reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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[]: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 --- agent/BACKLOG.md | 20 +++++++++ agent/HARDWARE-QUEUE.md | 65 +++++++++++++++------------ agent/JOURNAL.md | 37 ++++++++++++++- modules/home/hyprland.nix | 92 +++++++++++++++++++++++++++++--------- tools/monitor-fallback.nix | 14 +++--- 5 files changed, 172 insertions(+), 56 deletions(-) diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index df0375a..884a465 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -222,6 +222,26 @@ high-ROI, etc.) live in the journal + ROADMAP — not here.* ### Product / day-2 +### 114. Greeter ignores per-device keyboard layouts + +Found by Bernardo 2026-07-14: logging out while docked lands on tuigreet, +where his external keyboard (remembered as `us` via +`settings.keyboard.devices`) types the session layout `gb` — so the password +prompt fights him. Not a regression and not docking-related: per-device +layouts are applied with `hyprctl keyword device[]:kb_layout`, which +only exists inside a running Hyprland session, while tuigreet draws on a +kernel VT whose single keymap comes from `console.useXkbConfig` ← +`services.xserver.xkb.layout`. A VT structurally cannot do per-device +layouts, so this is a design gap, not a bug to patch. Options, cheapest +first: (a) document it and stop there; (b) a greeter layout-cycle key +(tuigreet has no such feature — would need the keymap swapped under it); +(c) host tuigreet inside a small Wayland compositor (cage/labwc), which +*does* get per-device XKB and would let the greeter honour the same +in-flake state the session uses — real work, and it changes the greeter's +whole rendering path (`modules/nixos/greeter.nix` themes tuigreet through +the 16 ANSI console slots, so (c) is not a drop-in). Worth deciding whether +the greeter is meant to be layout-aware at all before costing it. + - **NVIDIA first-class options** — **deferred past v1** (Bernardo 2026-07-10). Keep #59 commented install guidance; no `nomarchy.hardware.nvidia.*` until a hybrid maintainer + queue. diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index 6025542..4db869a 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -32,36 +32,43 @@ the **T14s** (webcam case). nomarchy-battery-charge-limit.service` stays false, `Result=success`, BAT0's `charge_control_end_threshold` still matches the limit, and the journal has no new `start-limit-hit`/`Failed with result` lines. -- [ ] **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. +- [ ] **Docking recovery round 8 (undock panel restore, 2026-07-14)** — + round 7 **FAILED** on the AMD dev box (Bernardo, 5–6 consecutive + unplugs, none recovered). Its whole diagnosis was wrong, and the way it + was wrong is the lesson: round 6 read `result=enable-timeout` ×2 + + `result=ok` ×1 as an intermittent race and retried the keyword. But the + keyword is **inert, not raced** — with ZERO enabled outputs (panel + disabled by the dock, external gone) Hyprland 0.55.4 accepts + `keyword monitor` (prints `ok`, exits 0) and never flushes it until a + DRM event arrives. Retrying an inert command 25×/poll for 6 polls just + bought 30s of black screen. Every `result=ok` in that journal was + **Bernardo plugging the cable back in** — his hotplug flushed the queued + rule and the next poll took the credit. Probed live 2026-07-14: keyword + inert across 4s and 5s in two runs, `dispatch forcerendererreload` also + inert, and only `hyprctl reload` worked — 99ms and 289ms, cable out. + The transition now escalates to `reload` when the keyword proves inert, + re-asserts the rule, and restores per-device keyboard layouts (a reload + drops runtime `device[…]:kb_layout` keywords). + **Already proven on hardware:** one real unplug, invoking the fixed + `nomarchy-display-transition undock eDP-1` directly with the watcher + paused — panel on and workspaces 1–3 home in 1.8s, journal + `keyword=inert escalate=reload` → `enable=via-reload` → `result=ok`. + **What is NOT proven, and is this round:** the *watcher-driven* path + (the running watcher calls the transition by baked store path, so it + only picks the fix up at relogin — the round-5 trap), and repetition. **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. + cable **at least five times**, plus once with the lid shut and once + while an external keyboard is plugged into the *laptop* (not the dock's + hub) — that last one is the only check on the keyboard-restore path. + **Pass:** the panel comes back every time with workspaces intact, the + external keyboard keeps its remembered layout, and `journalctl --user + -t nomarchy-display-transition -t nomarchy-display-watch --since + '-10 min'` shows `result=ok` for each undock with **no** + `result=enable-failed`. `keyword=inert escalate=reload` on every undock + is EXPECTED — that is the fix firing, not a fault. A `result=ok` + **without** a preceding `keyword=inert` line means the keyword flushed + on its own and the reload was never needed: interesting, worth + reporting, still a pass. - [ ] **Headphone jack-follow (last open count from rounds 4–6, 2026-07-14)** — rounds 4–6 opened five counts on the AMD dev box; four are now **confirmed fixed on hardware** by round 6 (Bernardo): wallpaper diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index eb8970d..0572946 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -19,7 +19,42 @@ Template: --- -## 2026-07-14 — docking round 6: undock left the laptop panel black (this commit) +## 2026-07-14 — docking round 7 FAILED; the keyword was inert, not raced (this commit) +- **Task:** HARDWARE-QUEUE docking round 7 result (Bernardo, AMD dev box) — + 5–6 consecutive unplugs, the panel never came back. Round 6's fix did + nothing because round 6's diagnosis was wrong. +- **Did:** Probed it live instead of re-reading the journal. With ZERO enabled + outputs, `hyprctl keyword monitor eDP-1,preferred,auto,1` is *inert* — + accepted, `ok`, exit 0, and never flushed until a DRM event arrives. Not a + race: inert across 4s and 5s in two runs. `dispatch forcerendererreload` is + inert too; only `hyprctl reload` escapes (99ms / 289ms, cable out). So the + transition issues the keyword, and escalates to `reload` only when + `monitors` proves it inert — then re-asserts the rule and calls + `nomarchy-keyboard-layout restore` per device, since a reload drops runtime + `device[…]:kb_layout` keywords. Also made the menu's `enable` prove itself. +- **The lesson, worth more than the fix:** round 6 built its race theory on + `enable-timeout` ×2 + `result=ok` ×1, and I extended it with a "phantom + re-add" theory from the `added= DP-2` lines. Both were the same artifact: + **every `result=ok` was Bernardo replugging the cable** because the screen + was black — his hotplug flushed the queued rule and the next poll claimed + the win. A watcher whose success is indistinguishable from the user working + around it will keep confirming whatever story you bring. The probe cost 10 + minutes and killed two rounds of theory. +- **Verified:** V3 (partial) — the fixed `nomarchy-display-transition undock + eDP-1` driven through a real unplug on the dev box: panel on, workspaces + 1–3 home, 1.8s, `keyword=inert escalate=reload` → `enable=via-reload` → + `result=ok`. Plus V2: `nix flake check --no-build` all pass; + `checks.docking-ux` + `checks.dock-audio` pass; shellcheck clean on + display-transition (profile-watch's lone SC2125 is pre-existing). +- **Pending:** HARDWARE-QUEUE round 8 — the *watcher-driven* path needs a + relogin (exec-once, baked store path) and repetition; the keyboard-restore + path is entirely untested (dev box's keyboard hangs off the dock hub, so it + leaves with the cable). New BACKLOG #114 (PROPOSED): tuigreet ignores + per-device layouts. +- **Next suggestion:** #114, or the dock direction's unverified panel-off + (it disables the panel trusting hyprctl's exit code — same class, latent). + +## 2026-07-14 — docking round 6: undock left the laptop panel black (superseded by round 7 above) - **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. diff --git a/modules/home/hyprland.nix b/modules/home/hyprland.nix index 4730939..d844c75 100644 --- a/modules/home/hyprland.nix +++ b/modules/home/hyprland.nix @@ -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[]: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 [external-rule]|undock |enable ]" >&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= diff --git a/tools/monitor-fallback.nix b/tools/monitor-fallback.nix index 293a66b..0afc48b 100644 --- a/tools/monitor-fallback.nix +++ b/tools/monitor-fallback.nix @@ -132,11 +132,15 @@ pkgs.testers.runNixOSTest { # 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. + # and that gap is exactly what shipped a black panel in rounds 6 and 7. + # The real fault needs ZERO enabled outputs: with the panel disabled and + # the external gone, Hyprland 0.55.4 accepts `keyword monitor` and never + # flushes it, so the panel stays dark until some DRM event arrives (only + # `hyprctl reload` escapes it — probed on hardware 2026-07-14). Note the + # QEMU abort above is that same degeneracy seen from the other side, so + # this harness cannot reach the state without dying in it: the undock here + # always runs while DP-1 is still enabled, i.e. the one case that never + # needed the fix. A pass below is NOT proof of the reload escalation. user(f"{transition} undock Virtual-1") machine.wait_until_succeeds( hy + "-j monitors' | jq -e 'any(.[]; .name == \"Virtual-1\")'", timeout=20