Wire AlsaAudioSink into SinkRecovery so a replugged device recovers - #46
Conversation
AlsaAudioSink was the only sink with a real device not wired into SinkRecovery. recover_() handled -EINTR/-EAGAIN, -EPIPE and -ESTRPIPE and treated everything else -- -ENODEV among them -- as a failed write: it logged, returned false, and left pcm_ open on hardware that was gone. Nothing before the next stream retired the handle, so write() returned 0, the sync task re-presented the same buffer, and the unthrottled ERROR repeated once per retry until the process restarted. Every error the three transient branches cannot clear in place is now device loss. handle_device_loss_() closes the device and spends SinkRecovery's inline attempt without making it, which escalates to a new poll() override that reopens at last_format_ behind the existing delay and budget. Taking device loss as the residual rather than as a list of errnos is deliberate: an errno wrongly left out restores the forever-spin, while one wrongly taken in costs a close and a couple of seconds of discard before poll() reopens a device that was there all along. The inline attempt is spent rather than made because snd_pcm_open() cannot be bounded the way PULSE_RECOVERY_TIMEOUT_MS bounds a Pulse reconnect, and on a plugin PCM it parses config and waits on a daemon socket with no timeout at all. Making that call on the sync task's thread, under device_mutex_, would break the rule the sink's threading model rests on. The failed prepare() following an underrun and the failed prepare() following a suspend route to the same helper. Both are reachable and both stranded the handle: a device pulled while the ring drains is seen as -EPIPE first, with the -ENODEV only surfacing from the prepare() after it. Also here, as consequences rather than scope: - configure() sets last_format_ before anything can fail and calls reset() at both success exits, never in open_device_() -- poll() calls that between rescan_due() and rescan_done(), where a reset() would refill the budget from inside the attempt spending it. Restructuring the fast path so both exits share a tail means a fast-path reopen failure now sets failed_, which it did not before. - write()'s discard path takes its frame size from last_format_ once close_device_() has zeroed bytes_per_frame_, so a sub-frame buffer now returns 0 rather than length. No new policy and no new constants. The escalation sequence is exactly tests/sink_recovery_test.cpp's escalate() helper, already covered there; the ALSA wiring itself needs hardware, and is recorded in docs/ROADMAP.md as an owed hardware pass rather than claimed as verified. Closes #45
There was a problem hiding this comment.
🟡 Changes recommended
Recovery performs an unbounded open while holding a mutex needed by writes, potentially blocking playback and the main loop indefinitely.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Integrates ALSA device-loss recovery with the shared SinkRecovery mechanism.
Changes:
- Detects device loss and schedules delayed reopen attempts.
- Preserves stream format for recovery.
- Updates recovery documentation and roadmap.
File summaries
| File | Description |
|---|---|
src/alsa_sink.h |
Declares ALSA recovery state and polling. |
src/alsa_sink.cpp |
Implements device-loss handling and reopening. |
docs/ROADMAP.md |
Documents ALSA recovery behavior. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 3
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The item 14 correction said a device pulled out mid-track "discarded audio and one ERROR per buffer". It discarded nothing: write() broke out of its loop and returned 0, so the sync task re-presented the same buffer against the dead handle and made no progress at all. Discarding is what the sink does now, and the distinction is the whole of the defect -- so getting it backwards in the one paragraph explaining it was worth fixing. The hardware pass likewise promised one ERROR for a powered-off DAC. An outage logs twice: recover_() names the device and the errno, and write()'s discard branch says what happens next, each latched once per outage. The property that matters is that neither repeats per buffer. Also records what holding device_mutex_ across poll()'s snd_pcm_open() costs -- a real pause in protocol handling, and a concurrent write() blocked on the mutex rather than honouring its timeout_ms -- along with why that is not a new risk: configure() already makes the same unbounded open on the main loop under the same mutex, on every stream rather than only on a lost device.
|
Went through Copilot's three. One was a real error in my prose and is fixed; the other two are accurate observations whose proposed remedies I've declined, with reasoning below so it's on the record rather than silently dropped. Accepted — the ROADMAP misdescribed the old behaviour (
|
Fixes #45. Reported downstream as
music-assistant/local-audio-addon#28.The defect
AlsaAudioSinkwas the only sink with a real device not wired intoSinkRecovery.recover_()handled-EINTR/-EAGAIN,-EPIPEand-ESTRPIPE, and everything else ---ENODEVamong them -- fell through to a barecli_log(ERROR, "alsa: %s", ...)andreturn false, leavingpcm_open andfailed_clear.All three callers are in
write()'s inner loop and treat false asbreak, sowrite()returned 0, the sync task re-presented the same buffer, and the unthrottled ERROR repeated once per retry.write()'spcm_ == nullptrdiscard path -- the code that would have coped -- was unreachable, because the handle outlived the hardware. Nothing before the next stream retired it, so only a restart cleared it.The fix
Every error the three transient branches cannot clear in place is now device loss.
handle_device_loss_()closes the device and spendsSinkRecovery's inline attempt without making it, escalating to a newpoll()override that reopens atlast_format_behind the existing 2 s -> 30 s delay and five-attempt budget. No new policy and no new constants.Two calls the issue deliberately left to this PR:
1. The inline attempt is spent rather than made.
snd_pcm_open()cannot be bounded the wayPULSE_RECOVERY_TIMEOUT_MSbounds a Pulse reconnect, and on a plugin PCM --defaulton a PipeWire host, or thealsa:pulseroute -- it parses config and waits on a daemon socket with no timeout at all. Making that call on the sync task's thread, underdevice_mutex_, would break the rule the sink's threading model rests on: thatwrite()never blocks unboundedly while holding it.SND_PCM_NONBLOCKis not the way out -- it changes the opened stream's semantics, whichwrite()'ssnd_pcm_wait()model depends on, and bounds neither the config parse nor a plugin's connect. Mechanically this isPulseAudioSink::reopen_in_place_()'s context-down branch, for the same reason.2. Device loss is the residual, not a list of errnos. The issue asked which set counts; the answer here is that an allowlist is the wrong shape. The two mistakes are not the same size -- an errno wrongly left out restores the forever-spin for anything we guessed wrong about, while one wrongly taken in costs a close and a couple of seconds of discard before
poll()reopens a device that was there all along, bounded bySINK_RESCAN_ATTEMPTS. This supersedes the-ENODEV/-ENXIO/-EIOquestion rather than answering it.That inversion also closes a second door an allowlist would have left open: the failed
prepare()after an underrun and the failedprepare()after a suspend both used toreturn falsewith the handle open and nothing armed. Both are reachable, and the first is how this very bug presents -- a device pulled while the ring drains is seen as-EPIPEfirst, with the-ENODEVonly surfacing from theprepare()after it. All three failure points now route through the one helper.Consequences, not scope
Named explicitly so the diff is legible:
configure()setslast_format_before anything can fail and callsreset()at both success exits, never inopen_device_()--poll()calls that betweenrescan_due()andrescan_done(), where areset()would refill the budget from inside the attempt spending it. Restructuring the fast path so both exits share a tail means a fast-path reopen failure now setsfailed_, which it did not before.write()'s discard path takes its frame size fromlast_format_onceclose_device_()has zeroedbytes_per_frame_, matchingpulse_sink.cpp. A sub-frame buffer therefore now returns 0 rather thanlength-- unreachable in practice, since the player hands over whole frames.pcm_ == nullptr, so the fast-path condition is simply false. A nullpcm_is the record.Behaviour worth knowing
The budget is per configured stream. A device that dies, recovers via
poll(), then dies again in the same stream gets nothing until the nextconfigure()--rescan_done(true)latches, andreset()only runs on a stream that really opened. That isSinkRecoveryworking as designed and matchesPulseAudioSink, but "recovered once, then silent" looks like a bug if you do not know to expect it.Testing
Green, with an honest limit. Builds with zero warnings and 412/412 ctest pass on Debian bookworm with
libasound2-dev(backendsnull, stdout, alsa) -- ALSA does not compile on macOS, so this was run in a container.No new tests, and deliberately: the escalation sequence
handle_device_loss_()performs is exactlytests/sink_recovery_test.cpp'sescalate()helper, already covered byAFailedReopenEscalatesToTheRescanandEveryFurtherWriteOfTheOutageIsToldToDiscard. That is the splitSinkRecoveryis device-free for. The ALSA wiring itself is not reachable without hardware.Nobody has yet pulled a DAC out of a running player and watched it come back. This is reasoned from the code and from the reporter's logs, not measured.
docs/ROADMAP.mdrecords it as an owed four-case hardware pass rather than claiming otherwise.(If you re-run the suite as root, three
StateStoretests fail -- root ignores the directory permission they rely on. As a non-root user all 412 pass.)Docs
docs/ROADMAP.mditem 14 claimedAlsaAudioSink"has nothing to do with a main-loop tick" -- the assumption that let ALSA out of that item, and so the root cause. Corrected in place rather than split into a new item, and positioned after item 14's PortAudio-only "What remains after this" paragraph, which is the opposite of true for ALSA. Item 20's caller list went from three to four; item 2's ALSA bullet cross-references item 14.Out of scope
Ask 3 on the issue -- a sink-health field on
format_status()-- is left alone. It is a separate change with a separate justification and applies to all four sinks.