diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-09 16:09:11 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-09 16:09:11 -0500 |
| commit | 3cf92e272426e72a1be7b60fa8d1ad9b2416ece2 (patch) | |
| tree | c572a42a6b65eb2933c0992b168a3a4b1d2ed7f7 | |
| parent | a8f7e9b9c089577b299afc04bd1e1be040197995 (diff) | |
| download | archsetup-3cf92e272426e72a1be7b60fa8d1ad9b2416ece2.tar.gz archsetup-3cf92e272426e72a1be7b60fa8d1ad9b2416ece2.zip | |
docs: spec the audio panel doctor
Answers the four questions the request asked, from a live survey rather than
from memory. There is no PulseAudio: the stack is pipewire, wireplumber, and
pipewire-pulse, all user-scope, so no remedy in this feature needs sudo — which
is why it mirrors the net panel's doctor rather than the maintenance console's
privileged verb table.
Two findings shape the design. pactl hangs against a server that accepts and
never answers, which is exactly the fault a doctor exists to diagnose, so every
probe is bounded. And the panel cannot diagnose itself, because pactl is the
layer most likely to be down — the probes read systemctl and pw-dump before
they touch it.
Filed the panel's own missing timeout as a separate bug. It freezes the audio
panel today, independently of any doctor.
Seven decisions left open; the spec is DRAFT and nothing gets built until they
close.
| -rw-r--r-- | docs/specs/2026-07-09-audio-doctor-spec.org | 258 | ||||
| -rw-r--r-- | todo.org | 5 |
2 files changed, 262 insertions, 1 deletions
diff --git a/docs/specs/2026-07-09-audio-doctor-spec.org b/docs/specs/2026-07-09-audio-doctor-spec.org new file mode 100644 index 0000000..611a6ca --- /dev/null +++ b/docs/specs/2026-07-09-audio-doctor-spec.org @@ -0,0 +1,258 @@ +#+TITLE: Audio Doctor — diagnose and repair a broken sound stack +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-09 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* DRAFT Audio Doctor — diagnose and repair a broken sound stack +:PROPERTIES: +:ID: 6ce3f2ef-052e-40cb-9067-42f8e665f813 +:END: +- [2026-07-09 Thu] DRAFT — written from the roam-inbox ask "look into a doctor button for the audio panel: what happens if pulseaudio or its equivalent is borked? would we know? how could we test for it? what remedies would we have?" Probe set, classifier, and remedy tiers below are grounded in a live survey of ratio's stack. Seven Decisions are open. + +* DRAFT Status +:PROPERTIES: +:ID: 1cc2b355-9450-4ace-bdb3-b935f7051918 +:END: +- [2026-07-09 Thu] DRAFT — spine complete, Decisions open. Not ready to build: the remedy tiers touch a running meeting's audio, and the classifier's precedence needs Craig's call before code. + +* Metadata + +| Field | Value | +|--------+-------------------------------------------------------------------------------------------------------------| +| Status | draft | +|--------+-------------------------------------------------------------------------------------------------------------| +| Owner | Craig Jennings | +|--------+-------------------------------------------------------------------------------------------------------------| +| Repo | dotfiles (=audio/= package); archsetup owns this spec | +|--------+-------------------------------------------------------------------------------------------------------------| +| Kin | net panel doctor (=net/diag.py=, =classify.py=, =repair.py=, =doctor.py=) — the architecture to mirror. | +| | Maintenance console (results wall, arm-to-fire) — the interaction idiom. | +|--------+-------------------------------------------------------------------------------------------------------------| + +* Summary + +The audio panel shows the sound graph and lets you drive it. It has no answer for the graph being *broken*. When PipeWire, WirePlumber, or the Pulse compatibility layer fails, the panel shows an empty or stale board and the user is left to remember service names. + +This adds a DOCTOR key to the audio panel — read-only diagnose, then a lightest-repair-first fix — mirroring the net panel's doctor, which already solves this shape for networking. + +* Problem / Context + +Craig's questions, answered from a live survey of ratio (2026-07-09): + +** "What happens if pulseaudio or its equivalent is borked?" + +There is no PulseAudio. The stack is three user-scope services: + +| Unit | Role | Failure looks like | +|--------------------------+---------------------------------------------+---------------------------------------------| +| =pipewire.service= | the media graph itself | no devices at all; everything silent | +|--------------------------+---------------------------------------------+---------------------------------------------| +| =wireplumber.service= | session manager: routing, policy, device | devices exist but nothing routes; defaults | +| | profiles, default picking | unset; profiles never applied | +|--------------------------+---------------------------------------------+---------------------------------------------| +| =pipewire-pulse.service= | Pulse protocol compat, via | =pactl= fails; the panel goes blind while | +| | =pipewire-pulse.socket= | audio may still work for others | +|--------------------------+---------------------------------------------+---------------------------------------------| + +The third row is the trap. The panel speaks =pactl=, so a dead =pipewire-pulse= blinds the panel even though PipeWire-native clients still play. The panel's own emptiness is therefore *not* evidence that audio is broken, and the doctor must not treat it as such. + +** "Would we know?" + +Today, no — and worse than no. Two findings from the live survey: + +1. *=pactl= hangs on a hung server.* Against a refused socket it fails in ~0ms ("Connection refused"). Against a socket that accepts and never answers, it blocks indefinitely — measured: still blocked at the 5s timeout. A hung server is precisely the case the doctor exists for, so *every probe carries a timeout*, and the doctor may not reuse the panel's =status.build_status()=, which has no timeout and would hang the GTK thread with it. + +2. *The panel cannot diagnose itself.* Its only data source is the layer most likely to be down. The doctor's probes therefore read =systemctl --user= (never blocks on the audio graph) and =pw-dump= / =wpctl= (PipeWire-native, alive when the Pulse layer is not) *before* they touch =pactl=. + +** "How could we test for it?" + +Four probe tiers, cheapest and most reliable first. Each is independently useful; the classifier below reads all four. + +*** Tier 1 — unit state (no audio IO, cannot hang) +=systemctl --user show -p ActiveState -p Result <unit>= for =pipewire=, =wireplumber=, =pipewire-pulse=, and the two sockets. Distinguishes =active= / =failed= / =inactive= / =not-found=. + +*** Tier 2 — graph liveness (PipeWire-native, survives a dead Pulse layer) +=pw-dump= with a timeout. Answers: does the graph exist, how many nodes, are there sinks and sources at all. =wpctl status= gives the same in human form. A graph with zero sinks is a different fault from a graph that will not answer. + +*** Tier 3 — Pulse compat reachability (the panel's own dependency) +=pactl info= under a hard timeout. Three outcomes, and they are *different faults*: answered / refused / hung. The hung case is the one that currently freezes tooling, and it is invisible to a naive "does pactl work" check because that check hangs too. + +*** Tier 4 — semantic health (only meaningful once 1–3 pass) +Default sink and source resolve to devices that exist; at least one sink is not =SUSPENDED= when a stream is playing; xrun and suspend lines in the unit journal since boot. Ratio's sink states today are =RUNNING= and =SUSPENDED=; =SUSPENDED= alone is normal idle, not a fault. + +** "What remedies would we have?" + +All three units are =--user= scope. *No privilege escalation is needed anywhere in this feature*, so the maintenance console's =priv.py= verb table and its =sudo -n= machinery are dead weight here and must not be copied in. This is the single largest architectural difference from the maint console, and the reason the audio doctor mirrors the *net* panel instead. + +Remedies, lightest first: + +| # | Remedy | Blast radius | Tier | +|---+-----------------------------------------------+----------------------------------------------+---------| +| 1 | =systemctl --user restart wireplumber= | routing/profiles re-applied; streams survive | Auto | +|---+-----------------------------------------------+----------------------------------------------+---------| +| 2 | Set default sink/source to a present device | one setting | Auto | +|---+-----------------------------------------------+----------------------------------------------+---------| +| 3 | Unmute + restore volume on the default device | audible, but reversible | Confirm | +|---+-----------------------------------------------+----------------------------------------------+---------| +| 4 | =systemctl --user restart pipewire-pulse= | Pulse clients drop and reconnect | Confirm | +|---+-----------------------------------------------+----------------------------------------------+---------| +| 5 | =systemctl --user restart pipewire= | *every* audio client's stream drops | Arm | +|---+-----------------------------------------------+----------------------------------------------+---------| + +Remedy 5 is the one that cuts audio out from under a live meeting. It gets the arm-to-fire treatment, and its arm line says so in words. + +* Goals and Non-Goals + +** Goals + +- Answer "is my sound stack broken, and if so where" without the user knowing a service name. +- Work *when the panel's own data source is down* — a doctor that needs =pactl= to answer is useless in the case it exists for. +- Never hang. Every probe is bounded; a probe that times out is a *finding*, not a stall. +- Repair from the lightest remedy that could plausibly fix the classified fault, exactly as the net doctor does. +- Read-only by default. Diagnose without touching anything; fixing is a separate, explicit press. + +** Non-Goals + +- Fixing a Bluetooth audio device that will not take an A2DP profile. That is the bluetooth panel's problem and it already has a doctor-shaped surface. +- Per-application stream routing or a full patchbay. Helvum and qpwgraph exist. +- Diagnosing hardware failure (a dead DAC, an unplugged cable). The doctor can report "sink present but never leaves SUSPENDED"; it cannot see a cable. +- Sample-rate, quantum, or latency tuning. A separate concern with a separate audience. +- Any use of =sudo=. If a remedy seems to need it, the remedy is wrong. + +* Design + +** For the user + +A DOCTOR key on the audio panel, beside the existing console keys. Pressing it streams findings into a results wall — the same wall idiom the maintenance console uses, so the interaction is already learned: + +#+begin_example +DOCTOR diagnose · read-only + ok pipewire.service active + ok wireplumber.service active + FAIL pipewire-pulse.service failed (Result=exit-code) + ok graph 4 sinks · 7 sources (via pw-dump) + FAIL pulse compat not answering — pactl refused + -- defaults not checked (pulse compat down) + + verdict: the Pulse compatibility layer is down. + PipeWire itself is healthy — native clients still play. + FIX restarts pipewire-pulse. +#+end_example + +The verdict names one fault, in the user's terms, and names the remedy it would run. Then a FIX key applies the lightest remedy for that verdict, streaming each step and re-probing after, so the wall shows repaired truth rather than an optimistic claim. + +When everything passes, the wall says so and offers nothing to press. + +** For the implementer + +Mirror the net panel's module split, which already separates these concerns cleanly: + +| Module | Responsibility | +|---------------------+------------------------------------------------------------------------------------------------| +| =audio/diag.py= | The four probe tiers. Pure IO, each bounded by a timeout. Returns a context dict; never raises | +| | on a broken stack, never blocks. | +|---------------------+------------------------------------------------------------------------------------------------| +| =audio/classify.py= | Pure. Context dict → one verdict, by precedence. Unit-tested against fixture contexts, no | +| | services involved. | +|---------------------+------------------------------------------------------------------------------------------------| +| =audio/repair.py= | One function per remedy. Each returns a result envelope; each re-probes the thing it claimed | +| | to fix. | +|---------------------+------------------------------------------------------------------------------------------------| +| =audio/doctor.py= | Orchestration: gather → classify → (optionally) attempt the lightest matching remedy → | +| | re-probe → emit the event stream. | +|---------------------+------------------------------------------------------------------------------------------------| + +Three constraints that fall out of the survey and are easy to get wrong: + +1. *Timeouts everywhere.* =cmd.run(..., timeout=N)= on every probe. A =pactl= call with no timeout will hang the doctor against the exact fault it is diagnosing. Test this with a socket that accepts and never answers — a refused socket does not reproduce it. + +2. *Probe order is load-bearing.* =systemctl= first (cannot hang), then =pw-dump= (alive when Pulse is dead), then =pactl= (may hang). Reversing this makes the doctor unable to report on the case it was built for. + +3. *The GTK thread never probes.* The panel already has a =bg()= worker; the doctor runs there and lands events on the main loop, as the maint console's =_stream= does. + +*** Classifier precedence (draft — see Decision 3) + +Terminal faults first, so a lower-layer failure is never reported as an upper-layer symptom: + +1. =pipewire.service= not active → *graph down*. Nothing else is meaningful. +2. =wireplumber.service= not active → *no session manager*. Devices exist, routing does not. +3. =pactl= hangs → *pulse compat hung*. Distinct from dead: a restart is the remedy, but the diagnosis differs and the user should see which. +4. =pipewire-pulse= not active, or =pactl= refused → *pulse compat down*. +5. Graph healthy, zero sinks → *no output devices*. +6. Default sink/source names a device that is absent → *stale default*. +7. Everything up, default device muted or at zero volume → *silenced, not broken* (a finding, not a fault; offer to unmute). +8. Otherwise → *healthy*. + +*** Testing + +Follows the four-layer pattern the maint console established: + +- *Unit, fakes.* =classify.py= against fixture contexts covering every verdict, including the hung-vs-dead distinction. =diag.py= against a fake =systemctl= / =pactl= / =pw-dump= on a temp PATH, one case per outcome. Pairwise over (unit state × graph state × pulse reachability) — three parameters with three, three, and four values each is 36 exhaustive, and pairwise covers the interactions in far fewer. +- *Timeout regression.* A test that stands up a Unix socket which accepts and never answers, points =PULSE_SERVER= at it, and asserts the probe returns a =hung= finding within its timeout rather than blocking. This is the test that would have caught the real behavior; write it first. +- *Live read-only.* =audio doctor= (no fix) on ratio and velox, asserting a healthy verdict. +- *Remedy, in a VM.* Restarting =pipewire= is not something to exercise on a live desktop. The archsetup VM harness runs remedies 1, 4, and 5 against a real stack. +- *GUI smoke.* AT-SPI over the DOCTOR key and the wall, on fixtures. + +* Alternatives Considered + +** Reuse the maintenance console's doctor rather than building one in the audio panel + +Rejected. The maint doctor's remedies are system-scope and route through =priv.py= and =sudo -n=. Every audio remedy is =--user= scope. Bolting audio onto that table means either giving audio remedies a privilege path they do not need, or special-casing them out of the mechanism that defines the table. The net panel already demonstrates the right shape for a user-scope doctor. + +** A single RESTART AUDIO button + +Rejected. It is remedy 5 with no diagnosis: it drops every client's stream to fix a problem that is usually remedy 1 or 2. It also teaches nothing — the user learns to press it for every symptom, including symptoms it cannot fix, such as a stale default device. + +** Probe with =pactl= only, since the panel already speaks it + +Rejected on evidence. =pactl= hangs against a hung server, and it is dead when the Pulse compat layer is dead — the two most interesting faults. It is the *last* probe, not the first. + +** Auto-repair on detection, without asking + +Rejected. Remedies 3 through 5 are audible and, in a meeting, disruptive. Diagnosis is free and should be; repair is a press. + +* Decisions [0/7] + +** TODO Where does the DOCTOR key live, and does it replace anything? +The net panel puts diagnose and repair behind one console key. The audio panel's console row already carries INPUT, OUTPUT, and PUSH TALK. Does DOCTOR join that row, or sit in a section header like the maint console's CLEAN UP / REVIEW & FIX pair? + +** TODO Is there an =audio doctor= CLI, and is it the same code path? +The maint console is CLI-first with the GUI as a face; the net panel ships =net doctor= and =net-doctor= as a make target. A CLI here makes the whole feature testable without a display and usable from a TTY when the desktop's sound is dead — arguably the most valuable case. Confirm CLI-first, and whether it prints the same wall text. + +** TODO Confirm the classifier precedence above, particularly rows 3 and 4. +Should "pulse compat hung" and "pulse compat down" be one verdict with a detail line, or two verdicts? They share a remedy (restart =pipewire-pulse=) but they are different failures, and the hung case is the one that freezes other tooling. + +** TODO Does the panel's own status build get the same timeout treatment? +=status.build_status()= calls =pactl= with no timeout today. Against a hung server the panel freezes, not just the doctor. Fixing that is a small change with real value and is arguably a prerequisite bug fix rather than part of this feature. Split it out? + +** TODO Which remedies are Auto, which Confirm, which Arm? +The table above proposes 1–2 Auto, 3–4 Confirm, 5 Arm. Remedy 4 drops Pulse clients briefly — is a Confirm enough, or does it want the arm treatment too when a stream is currently playing? + +** TODO Should the doctor refuse to run a disruptive remedy while audio is playing? +A "a stream is active — this will interrupt it" refusal, overridable by pressing again, would mirror the maintenance console's live-update guard. It is the same shape of problem: an operation that is fine when idle and destructive mid-use. Worth the machinery, or is the arm line's wording enough? + +** TODO Do xruns belong in v1? +Tier 4 can count xrun and suspend lines in the unit journal. That is a *quality* signal, not a broken/working one, and it has no remedy in this feature's vocabulary. Report it as a note, or leave it out entirely until there is something to do about it? + +* Implementation phases + +Sequenced so each phase is independently green and independently useful. Nothing here starts until the Decisions are closed. + +** Phase 0 — the timeout bug fix (prerequisite) +Give =status.build_status()= and every existing =pactl= call a timeout, with a regression test using an accept-and-never-answer socket. This is a real bug on its own: a hung audio server currently freezes the audio panel. Ships independently of the doctor. + +** Phase 1 — =diag.py=, the four probe tiers +Bounded probes, a context dict, fakes for every outcome including hung. No classifier, no GUI. =audio diag --json= prints the context. + +** Phase 2 — =classify.py=, the verdict +Pure function, fixture-driven, pairwise over the parameter space. =audio doctor= (read-only) prints findings and a verdict in the CLI. Already useful from a TTY at this point. + +** Phase 3 — =repair.py= + =doctor.py=, the fix path +One function per remedy, each re-probing what it claims to fix. =audio doctor --fix= applies the lightest matching remedy. VM-tested for remedies 1, 4, and 5. + +** Phase 4 — the DOCTOR key and the results wall +GUI face over the CLI. Streams events to a wall, off the GTK thread. AT-SPI smoke over the key, the wall, and the fix path on fixtures. + +** Phase 5 — flip this spec to IMPLEMENTED +With a history line naming what shipped and what was left out. @@ -1133,7 +1133,10 @@ Not waybar — the tmux status line. status-left-length was never set, so tmux c The session section truncates; give it more characters, and keep the window sections that follow from running into it. Screenshot: [[file:/home/cjennings/pictures/screenshots/2026-07-07_145236.png][2026-07-07_145236.png]] ** TODO [#C] Doctor button for the audio panel :feature:audio: -What happens when pipewire or pulseaudio is broken — would we know? Needs the probe set, the test for each fault, and the remedy tier before anything gets built. Design first. +What happens when pipewire or pulseaudio is broken — would we know? Spec written 2026-07-09: [[file:docs/specs/2026-07-09-audio-doctor-spec.org][docs/specs/2026-07-09-audio-doctor-spec.org]] (DRAFT, 7 open decisions). Grounded in a live survey of ratio's stack: there is no PulseAudio (pipewire + wireplumber + pipewire-pulse, all user-scope, so no sudo anywhere), =pactl= hangs against a hung server, and the panel can't diagnose itself because =pactl= is the layer most likely to be down. Mirrors the net panel's diag/classify/repair/doctor split, not the maint console's priv-verb table. + +*** TODO [#B] Audio panel freezes against a hung audio server :bug:audio:solo: +Fell out of the audio-doctor survey. =status.build_status()= calls =pactl= with no timeout. Against a socket that accepts and never answers, =pactl= blocks indefinitely (measured: still blocked at 5s), so a hung pipewire-pulse freezes the panel rather than degrading it. A refused socket fails in ~0ms and does *not* reproduce this — the regression test needs an accept-and-never-answer socket. Phase 0 of the audio-doctor spec, but it's a real bug that ships on its own. ** TODO [#C] Telega coredump recurrence tell :bug:maint: The maintenance console's coredump metric flagged telega-server on ratio (8 coredumps) and velox (18). Root cause was a version skew: the Dockerized =zevlg/telega-server:latest= is frozen at the 2026-06-05 build while the installed elisp lagged at 20260513, so the newer server's plist parser choked on the older elisp's output. .emacs.d fixed it by upgrading telega to 20260706 on both machines (docker kept, =docker pull= is a no-op against the frozen image). Host-coredump pollution should stop. If zevlg later pushes a =:latest= that outruns the installed elisp, the skew and the coredumps recur — the tell is a fresh =tdat_plist_value:500= assertion in =~/.telega/telega-server.log=. The durable escape is a host-native pinned TDLib build, at the cost of an AUR source build. |
