aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/specs/2026-07-09-audio-doctor-spec.org21
-rw-r--r--todo.org8
2 files changed, 19 insertions, 10 deletions
diff --git a/docs/specs/2026-07-09-audio-doctor-spec.org b/docs/specs/2026-07-09-audio-doctor-spec.org
index 611a6ca..5c4f844 100644
--- a/docs/specs/2026-07-09-audio-doctor-spec.org
+++ b/docs/specs/2026-07-09-audio-doctor-spec.org
@@ -8,12 +8,13 @@
: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.
+- [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. Six Decisions are open; Phase 0 shipped ahead of the rest.
* DRAFT Status
:PROPERTIES:
:ID: 1cc2b355-9450-4ace-bdb3-b935f7051918
:END:
+- [2026-07-09 Thu] DRAFT — Phase 0 shipped (dotfiles =4d42eb3=) and its Decision closed. The first draft claimed =build_status()= had no timeout and froze the panel; driving it disproved that — =pactl.run()= already times out. The real bug was narrower: two of the four reads sat outside the degrade guard, killing waybar's audio module. The claim is corrected in place rather than quietly dropped.
- [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
@@ -61,7 +62,7 @@ The third row is the trap. The panel speaks =pactl=, so a dead =pipewire-pulse=
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.
+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*. The engine's own =pactl.run()= already does this and raises =PactlTimeout=; the doctor must do the same, and must never call a bare =subprocess.run=.
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=.
@@ -165,7 +166,7 @@ Mirror the net panel's module split, which already separates these concerns clea
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.
+1. *Timeouts everywhere, and guard every read.* Bound every probe. Beyond that: a function that promises to degrade must guard *all* of its reads, not the first two. That was the Phase 0 bug. Test with a fault that answers early calls and hangs on later ones — a server that hangs on the first call masks everything behind it, and a refused socket does not reproduce the hang at all.
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.
@@ -212,7 +213,7 @@ Rejected on evidence. =pactl= hangs against a hung server, and it is dead when t
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]
+* Decisions [2/8]
** 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?
@@ -223,8 +224,11 @@ The maint console is CLI-first with the GUI as a face; the net panel ships =net
** 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?
+** DONE Does the panel's own status build get the same timeout treatment?
+CLOSED: [2026-07-09 Thu]
+Investigated and answered: it already had one. =pactl.run()= defaults to =timeout=8= and raises =PactlTimeout=, so the panel degrades rather than freezing. The draft of this spec claimed otherwise; the claim was wrong.
+
+What the investigation *did* find is a narrower, real bug, now fixed (dotfiles =4d42eb3=): =build_status()= guarded its two device-list reads but called =default_sink()= and =default_source()= outside the guard, so a server that answered the lists and then stopped answering raised =PactlTimeout= out of a function whose contract is to degrade. =waybar-audio= calls it with nothing catching it, so the bar's audio module died rather than dimming. A server that hangs on the *first* call never exposed this — =list_sinks= raised inside the guard and masked everything behind it, which is why the obvious test would have passed against broken code.
** 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?
@@ -239,8 +243,9 @@ Tier 4 can count xrun and suspend lines in the unit journal. That is a *quality*
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.
+** DONE Phase 0 — the degrade-on-hang bug fix (prerequisite)
+CLOSED: [2026-07-09 Thu]
+Shipped ahead of the rest (dotfiles =4d42eb3=). =build_status()= now guards all four pactl reads, not just the two list reads, so a half-wedged server dims the bar's audio module instead of killing it. =fake-pactl= grew =FAKE_PACTL_SLEEP_DEFAULTS= to express "answers the lists, hangs on the defaults" — the blanket sleep knob cannot, because the first call would hang and mask the bug.
** 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.
diff --git a/todo.org b/todo.org
index 9e681c3..b3203fd 100644
--- a/todo.org
+++ b/todo.org
@@ -1135,8 +1135,12 @@ The session section truncates; give it more characters, and keep the window sect
** TODO [#C] Doctor button for the audio panel :feature:audio:
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.
+*** 2026-07-09 Thu @ 14:12:00 -0500 Fixed: waybar's audio module died against a half-wedged server
+My first read of this was wrong. =pactl.run()= already carries =timeout=8= and raises =PactlTimeout=, so the panel degrades and does not freeze. Correcting the record rather than leaving the claim standing.
+
+The real bug, found by driving it: =build_status()= guarded its two device-list reads but called =default_sink()= and =default_source()= outside the guard. A server that answered the lists and then stopped answering raised =PactlTimeout= straight out of a function whose whole contract is to degrade to =ok: False=. =waybar-audio= calls it with nothing catching it, so the bar's audio module died instead of dimming; the GTK panel survived only because its background worker catches everything.
+
+A server that hangs on the *first* call never exposes this — =list_sinks= raises inside the guard and masks the unguarded reads behind it — so the obvious regression test passes against broken code. =fake-pactl= grew =FAKE_PACTL_SLEEP_DEFAULTS= to express "answers the lists, hangs on the defaults". Verified end to end: =waybar-audio= now emits =class: degraded= / "Audio graph unavailable" and exits 0. dotfiles =4d42eb3=; full =make test= green.
** 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.