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