From 9e64358225ac33f059a377d5363c0f27856f0b0b Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Thu, 16 Jul 2026 09:43:14 +0100 Subject: [PATCH] =?UTF-8?q?fix(audio):=20#138=20=E2=80=94=20reprobe=20esca?= =?UTF-8?q?lates=20to=20a=20graph=20restart,=20never=20opens=20with=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bernardo's Meet calls lost their microphone because our own dock-audio reprobe restarted PipeWire/Pulse/WirePlumber on every monitoradded (#100). Chromium's audio service does not reconnect when the server disappears under it, so it kept running and enumerated nothing; Zoom, started fresh into a settled graph, looked fine and made it read like a browser bug. reprobe now escalates cheapest-first — select → repair a parked card's profile → restart the graph — so an ordinary plug never touches the graph and no client loses its connection. wait_for_dock_sink polls for the sink the plug is about to publish, so "not yet" is not mistaken for "not ever" and cannot fall through to the restart. The restart stays as rung 3 for the codec that only publishes its route after a re-probe: the one claim the original comment made, and the only one it never demonstrated. Verified on the dev box against the live graph: rung 1 selects the same sink the restart-first path picked (HiFi__HDMI1__sink) in 2.0s instead of ~3.5s with pipewire/pulse/wireplumber MainPIDs unchanged; rung 3 reachable and restarts+retries. Diagnosis proof was ordering across four boots — restart-after-Chromium in both broken sessions, 24s before it in the healthy one — and Bernardo reproduced on demand with a dock/undock. Sweep: BACKLOG #138 deleted, ROADMAP gets the design record (incl. the stale restart-first sentence in the #100 entry), V3 queued on the dev box (relogin first — the watcher is exec-once), journal entry. Co-Authored-By: Claude Opus 4.8 --- agent/BACKLOG.md | 51 ------------------------------------- agent/HARDWARE-QUEUE.md | 13 ++++++++++ agent/JOURNAL.md | 32 +++++++++++++++++++++++ docs/ROADMAP.md | 30 +++++++++++++++++++--- modules/home/dock-audio.nix | 48 +++++++++++++++++++++++++++++----- 5 files changed, 113 insertions(+), 61 deletions(-) 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"