diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index f623b4c..3f3d476 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -211,57 +211,6 @@ scaled splash — the LUKS prompt especially, since a passphrase box half off the panel is the failure that matters. Pass = docked boot + shutdown centred on both heads; undocked unchanged. -### 138. Our dock-audio reprobe restarts PipeWire under running apps (Chromium loses its devices) - -Bernardo 2026-07-16, refined by his own hypothesis (pipewire restarts → -Chromium never reconnects) — which the journal confirms, and names **our own -code as the thing doing the restarting**. Symptom: Google Meet reports no mic -or speakers in native Chromium; Zoom is unaffected; it did **not** reproduce on -a fresh boot, only after days of uptime + a hibernate. - -**Mechanism.** `modules/home/dock-audio.nix:153` — on every `monitoradded`, the -`reprobe` path runs `systemctl --user restart pipewire.service -pipewire-pulse.service wireplumber.service`. A full graph restart, on every -physical monitor plug (#100). Chromium's audio service does not re-establish -its PulseAudio connection when the server disappears under it, so it keeps -running with a dead one and enumerates nothing — hence Meet's wording, hence -"restarting Chromium fixes it". Zoom survives because it is a Flatpak that -reconnects, and because it is generally started fresh into an already-restarted -graph. **Hibernate is not the cause** — it is only how a Chromium survives long -enough to still be running when a plug event arrives; resume-while-docked then -generates the `monitoradded` that fires the reprobe. - -**Evidence — ordering is the whole tell** (`Started app-org.chromium.Chromium-*.scope` -vs `reprobe-start`, four boots, no exceptions): - -| Session | Chromium | Graph restart | Order | Meet | -|---|---|---|---|---| -| boot -3 (Jul 14→15) | 14:06:49 | 14:07:52 (+2 more; then 10:52 next day) | **after** | broken | -| boot -2 (Jul 15 daytime) | 11:10:34 | 11:31:22 | **after**, +21 min | broken | -| boot 0 (Jul 16, fresh) | 08:04:19 | 08:03:55 | **before**, −24 s | works | - -Today is the negative control, and it is luck, not a fix: the restart missed -Chromium by 24 seconds. **Prediction that confirms this outright — dock/undock -now, then reload Meet: it should break in the current session**, with no reboot -and no hibernate. That is the cheap V3 for the diagnosis, and it is also the -regression test. - -**What to fix.** Not Chromium — the graph restart is a sledgehammer we -inherited: the header comment justifies it only as "the recovery used by the -old working flake", i.e. it was never shown to be *needed*, while its collateral -(every long-lived audio client dropped) was never priced. It is also not rare — -boot -3 took three restarts in 82 seconds (14:07:52 / 14:08:58 / 14:09:09; -the `mkdir` debounce only collapses events within one run, not a plug storm), -and each one drops audio for every app on the machine. Shape: **try selection -first, restart only if nothing routable appears** — `select_first_dock` already -falls back to `repair_dock_cards`, which fixes the parked-card case -(`set-card-profile`) *without* touching the graph, so the restart may have no -remaining job at all. If some HDMI codec really does need it (the other claim -in that comment), prove it on this hardware and scope the restart to that case. -Pass = plug/unplug the dock with Chromium open → output still follows to the -monitor **and** Meet still sees its devices; `journalctl -t nomarchy-dock-audio` -shows no graph restart on the ordinary path. - ### 139. Terminal floats open full-screen — % size rules are ignored, and Kitty remembers Bernardo 2026-07-16: calcurse and nomarchy-doctor float but fill the screen — diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index e222897..3e514e4 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -509,6 +509,19 @@ Everything else below stays open; order is convenience, not a gate. active and PPD switching governors; opt-ins: ROCm (`rocminfo`, a GPU PyTorch/Ollama smoke) and the XDNA NPU driver loading. - [ ] **Fingerprint** — `fprintd-enroll` + (opt-in PAM) login/sudo. +- [ ] **#138 dock audio does not break a running browser** — the plug event + itself is the only thing V2 could not do (the tool was driven by hand; + Hyprland calls the same entry point). **Relogin first** — the watcher is + `exec-once`, so `nomarchy-home` alone leaves the old process running and + you would be testing nothing (round 6's lesson). Then, with Chromium + **already open** on a Meet call: dock/undock. Pass = output still follows + to the monitor, **Meet keeps its mic and speakers without restarting the + browser**, and `journalctl --user -t nomarchy-dock-audio` shows + `selected=…` with **no** `action=graph-restart-fallback`, while + `systemctl --user show -p MainPID --value pipewire.service` is unchanged + across the plug. If a `graph-restart-fallback` line does appear, rung 3 + fired on a healthy plug — that is a bug, not the recovery working: file + it with the surrounding journal. - [ ] **System ▸ Firmware menu on real LVFS hardware** (item #43, `nomarchy-menu firmware`) — on a machine whose firmware/SSD/dock is on LVFS: open Menu ▸ System ▸ **Firmware**; confirm the terminal runs diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 58e262e..943e380 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -19,6 +19,38 @@ Template: --- +## 2026-07-16 — #138 dock audio: the graph restart was the bug (44aac0f + this) +- **Task:** #138 — Meet "no mic or speakers" in Chromium; Zoom fine; no repro + on a fresh boot. Bernardo's hypothesis (pipewire restarts → Chromium never + reconnects) was right; the restarter turned out to be **us**. +- **Did:** `dock-audio.nix` `reprobe` restarted PipeWire/Pulse/WirePlumber on + every `monitoradded` (#100). Now escalates cheapest-first: select → repair a + parked card → restart. Added `wait_for_dock_sink` so a slow sink is not read + as "nothing to select" and cannot fall through to the restart. Filed #137/ + #139/#140/#141 from the same T14s round in 44aac0f. +- **Verified:** V0 `nix flake check --no-build`. **V2/V3 on the dev box** (real + hardware, real graph): built the HM activation package with + `--override-input nomarchy path:…` and ran the real tool — rung 1 selects the + *same* sink the restart-first path picked (`selected=…HiFi__HDMI1__sink`) in + 2.0s vs ~3.5s, with pipewire/pulse/wireplumber MainPIDs **unchanged**; rung 3 + is reachable and restarts+retries as designed. Diagnosis proof: `Started + app-org.chromium.Chromium-*.scope` vs `reprobe-start` across 4 boots — + restart-after-Chromium in both broken sessions, 24s *before* in the healthy + one; Bernardo then reproduced on demand with a dock/undock (09:35:30). +- **Pending:** V3 queued (dev box): real plug with Chromium open on Meet — + **relogin first**, the watcher is `exec-once` (round 6's lesson). Bernardo + must `nomarchy-home` to pick this up; his Chromium needs one restart to + regain audio (his own 09:35 test killed it, not the fix). +- **Process note (mine, worth not repeating):** the rung-3 test was meant to be + safe via a stubbed `systemctl`, but the `sed` anchored on leading whitespace + that **Nix's indented strings strip**, so the stub never applied and the run + restarted his live graph for real. It came back clean (services active, dock + sink still default) and cost nothing extra only because Chromium was already + broken. Lesson: when a test's safety depends on a substitution, **assert the + substitution landed** before running the thing it protects you from. +- **Next suggestion:** #139 (terminal floats — two faults, both root-caused and + cheap) or #141 (4 jsonc lines, `$TERMINAL` absent from Waybar's env). + ## 2026-07-15 — #107 upgrade path proven; state-read tip fixed - **Task:** Bernardo feared rebuilding on the state.json rename ("what if it doesn't boot"). Verify the real upgrade path from a legacy checkout. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cf8050b..4897087 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -736,9 +736,11 @@ Design/decision records and a running log of shipped work (items marked private process group on cleanup, and validates/cleans a stale runtime PID after an unclean exit. Normal undocked lid-close suspend resumes after release. Audio now uses `monitoradded` as the fresh-plug intent boundary: - after a debounce and two-second hardware settle it restarts PipeWire, - PipeWire Pulse, and WirePlumber, then explicitly selects the highest-priority - available sink using the original WirePlumber regex data. Generic + after a debounce and two-second hardware settle it restarted PipeWire, + PipeWire Pulse, and WirePlumber, then explicitly selected the highest-priority + available sink using the original WirePlumber regex data (the restart was + demoted to a last resort by #138 below — the selection is the part that + works). Generic `change:sink`/card events never select, so a manual speaker choice sticks; the subscription loop reconnects after graph restarts. Journal lines record the trigger and selected/no-candidate result. `checks.docking-ux` syntax- and @@ -779,6 +781,28 @@ Design/decision records and a running log of shipped work (items marked process lesson: both watchers are `exec-once`, so `nomarchy-home` leaves the *old* processes running and two of the four reports were never testing the fix — **relogin before testing a watcher**. V3 is queued as round 6. + ✓ **Dock audio round 7 — the graph restart was never the fix (#138, + 2026-07-16):** Bernardo's Meet calls kept losing their microphone; the cause + was our own reprobe restarting PipeWire on **every** monitor plug. Chromium's + audio service does not reconnect when the server goes away under it, so it + runs on enumerating nothing — and Zoom, started fresh into a settled graph, + looked fine and made it read like a browser bug. Ordering across four boots + was the proof: restart-after-Chromium in both broken sessions, restart 24s + *before* Chromium in the one healthy session; a dock/undock on demand then + reproduced it live. `reprobe` now escalates cheapest-first — select → repair + a parked card's profile → restart the graph — so the common plug never + touches the graph and no client loses its connection. Measured on the dev + box: selection alone picks the *same* sink the restart-first path picked, in + 2.0s instead of ~3.5s, with PipeWire/Pulse/WirePlumber PIDs unchanged. The + restart survives as rung 3 for the codec that only publishes its route after + a re-probe — the one claim the original comment made and never demonstrated. + The lesson is the shape of the bug, not the audio: the restart was inherited + from "the old working flake" as a recovery, ran first for a session-day, and + its collateral was never priced. **A cure carried over from a working system + is still an unproven cure** — and a sledgehammer's cost lands on whoever was + already holding a connection, which is nobody at login and everybody at 11am. + A slow-appearing sink is polled for (`wait_for_dock_sink`) rather than read + as "nothing to select", so the ordinary plug cannot fall through to rung 3. ✓ **Conditional layout indicator + swap parity (#109, 2026-07-14):** the indicator answers "which layout am I typing in?", so it earns bar space exactly when that has more than one answer — not when a device merely diff --git a/modules/home/dock-audio.nix b/modules/home/dock-audio.nix index 2a12bdf..37b67db 100644 --- a/modules/home/dock-audio.nix +++ b/modules/home/dock-audio.nix @@ -1,11 +1,19 @@ # Automatic audio-output follow for docks / external monitors (#100). -# WirePlumber's stored default outranks priority.session, and some HDMI -# codecs only expose their usable route after the display hotplug. The -# Hyprland monitor watcher therefore calls `reprobe monitoradded` for the -# unambiguous physical-plug event: restart the graph (the recovery used by -# the old working flake), wait for it to settle, then explicitly select an -# available dock-class sink. Ordinary sink changes never select anything, -# so a manual speaker choice sticks until a fresh monitor plug. +# WirePlumber's stored default outranks priority.session, so the Hyprland +# monitor watcher calls `reprobe monitoradded` for the unambiguous physical- +# plug event and we explicitly select an available dock-class sink. Ordinary +# sink changes never select anything, so a manual speaker choice sticks until +# a fresh monitor plug. +# +# `reprobe` escalates, cheapest first, and only as far as it must (#138): +# select → repair a parked card's profile → restart the graph. The restart +# is last because it is not free — it drops every client's connection to the +# server, and clients that never reconnect (Chromium's audio service, hence +# "Meet says I have no microphone") stay broken until the app itself is +# restarted. It ran first here for one session-day because the old working +# flake used it as a recovery; nothing had shown it was *needed*. Keep it as +# the floor for the codec that only publishes its route after a re-probe, and +# leave the common path — the sink is simply there to be chosen — untouched. { config, lib, pkgs, ... }: let @@ -65,6 +73,19 @@ let headphone_sinks() { sinks_matching '${headphoneSinkRe}'; } default_sink() { "$PACTL" get-default-sink 2>/dev/null; } + # A dock sink appears some short time after the plug, not with it. Poll + # before concluding there is nothing to select: "not yet" and "not ever" + # look identical in one shot, and treating the first as the second is what + # would drive an ordinary plug down to the graph restart. + wait_for_dock_sink() { + tries=0 + while [ "$tries" -lt 20 ]; do + [ -n "$(dock_sinks)" ] && return 0 + tries=$((tries+1)); sleep 0.25 + done + return 1 + } + select_first() { # $1 = sink lister, $2 = observable trigger lister="$1"; trigger="$2" candidate=$("$lister" | ${pkgs.coreutils}/bin/head -n 1) || candidate= @@ -150,6 +171,19 @@ let trap '${pkgs.coreutils}/bin/rmdir "$lock" 2>/dev/null || true' EXIT INT TERM log "trigger=$trigger action=reprobe-start" sleep 2 + wait_for_pulse || { log "trigger=$trigger result=pulse-unavailable"; exit 1; } + + # Rung 1 + 2: the sink is there to be chosen, or a parked card needs + # its profile repaired first (select_first_dock does both). This is + # the whole job on a healthy plug — and it keeps every audio client's + # connection alive, which is the point. + wait_for_dock_sink || log "trigger=$trigger result=no-dock-sink-yet" + select_first_dock "$trigger" && exit 0 + + # Rung 3: nothing routable even after the card repair. Restart the + # graph — the inherited recovery, now confined to the case that has + # actually run out of cheaper options — and try once more. + log "trigger=$trigger action=graph-restart-fallback" "$SYSTEMCTL" --user restart \ pipewire.service pipewire-pulse.service wireplumber.service \ >/dev/null 2>&1 || log "trigger=$trigger action=graph-restart-returned-error"