Following up from #55 (comment), as @chrisuthe asked, with the code re-read on main at e980128 now that #55 has landed.
What happens
Within one configured stream, the sink recovers from the first device outage and from no outage after that. The player keeps reporting state: playing and stream: receiving, the device is back in /proc/asound, and the output is silent until a new stream reconfigures the sink or the player is restarted.
Reproduced on a D-Link DIR-3040 running OpenWrt: unplug the USB DAC during playback, plug it back, let it recover, then unplug and plug it back a second time in the same track. The log for the second outage stops after the loss and never retries:
19:31:00 E audio: alsa: 'hw:0,0' is gone (No such device)
19:31:14 I audio: alsa: 'hw:0,0' is back -- recovered without waiting for the next stream
19:31:20 I sendspin.sync_task: Regained sync, reporting synchronized
19:32:10 E audio: alsa: 'hw:0,0' is gone (No such device)
19:32:10 E audio: alsa: 'hw:0,0' is not open -- discarding audio until it is back or a stream reconfigures it
(nothing further; sub0/status stayed closed/absent until the player was restarted)
The full run with the sampled /proc/asound counters is in the PR comment linked above.
Why, on current main
The recovery budget is spent by success rather than by attempts, and the reopen tier is spent without ever being tried:
AlsaAudioSink::handle_device_loss_() (src/alsa_sink.cpp:452) takes the reopen and immediately gives it back as a failure — reopen_due() then reopen_done(false) — with the comment "Spend the inline reopen unmade: snd_pcm_open() is unbounded, so escalate to poll()". So every outage escalates straight to the rescan, and reopen_spent_ is latched from the first one.
- The rescan in
poll() succeeds and calls rescan_done(true) (src/alsa_sink.cpp:725), which takes the same branch as running out of attempts: if (recovered || rescan_attempts_ >= SINK_RESCAN_ATTEMPTS) { rescan_spent_ = true; } (src/sink_recovery.cpp:64).
- The second outage calls
reopen_due(), finds reopen_spent_, and escalates; escalate_() (src/sink_recovery.cpp:103) only owes a rescan if (!rescan_spent_ && !rescan_in_flight_), and rescan_spent_ is now true. Nothing is owed, pending() is false, and poll() returns on its fast path forever.
reset() is reached only from configure() (src/alsa_sink.cpp:484 and :497), so only a new stream clears any of it.
Because step 1 always burns the reopen unmade, step 2 always runs, so this is deterministic rather than intermittent: the second outage in any configured stream cannot recover, and the four remaining attempts of SINK_RESCAN_ATTEMPTS = 5 are unreachable.
Two smaller things fall out of the same reading:
reopen_done(true) carries the comment "The rescan stays in hand for the next outage" — exactly the intent that is missing one tier down — but it has no caller, since the only call site passes false.
src/sink_recovery.h:35 documents the contract as "Budget is per configured stream: one reopen, then up to SINK_RESCAN_ATTEMPTS rescans", which reads as five rescans available across the stream. In practice one success consumes all of them.
Suggestion
Have a successful rescan return the budget instead of latching it: in rescan_done(recovered), clear rescan_spent_ and zero rescan_attempts_ on success, and keep the latch for exhaustion only. Each outage then gets its own escalation ladder with the delays starting over, while a device that is genuinely gone still gives up after five tries.
Whether the reopen tier should also come back on success is a separate call — it is spent unmade today, so on its own it changes nothing.
This is not a regression from #55; the same rescan_done(true) path is in v0.1.6.
Relation to #63
#63 is about what happens after a recovery: the lookahead the sink swallowed during the outage, the single sync error reported at reopen, and the silence until the timeline catches up. This one is about there being no recovery at all the second time — the sink never reopens, so nothing reaches the sync task to report. They meet in the same poll() path but are independent: pacing the discard would not give a second outage its attempts back, and returning the budget would not shorten the gap after the first one.
Environment
|
|
| Build |
main at e980128, behaviour verified against PR #55 head c5b5279 (sendspin-cpp v0.8.0) |
| Host |
D-Link DIR-3040, OpenWrt 25.12.5, ramips/mt7621 (MIPS32 little-endian, musl), kernel 6.12.94 |
| Output |
-o hw:0,0 --buffer-ms 100 (4800-frame ring, 960-frame period) |
| Sink |
generic USB Audio Class 1 adapter, S16_LE 2 ch 48000 Hz only |
| Server |
Music Assistant, FLAC 48 kHz/16-bit |
Happy to test a patch on the same hardware — the replug is a physical one, so this path gets exercised for real rather than simulated.
Following up from #55 (comment), as @chrisuthe asked, with the code re-read on
mainate980128now that #55 has landed.What happens
Within one configured stream, the sink recovers from the first device outage and from no outage after that. The player keeps reporting
state: playingandstream: receiving, the device is back in/proc/asound, and the output is silent until a new stream reconfigures the sink or the player is restarted.Reproduced on a D-Link DIR-3040 running OpenWrt: unplug the USB DAC during playback, plug it back, let it recover, then unplug and plug it back a second time in the same track. The log for the second outage stops after the loss and never retries:
The full run with the sampled
/proc/asoundcounters is in the PR comment linked above.Why, on current
mainThe recovery budget is spent by success rather than by attempts, and the reopen tier is spent without ever being tried:
AlsaAudioSink::handle_device_loss_()(src/alsa_sink.cpp:452) takes the reopen and immediately gives it back as a failure —reopen_due()thenreopen_done(false)— with the comment "Spend the inline reopen unmade: snd_pcm_open() is unbounded, so escalate to poll()". So every outage escalates straight to the rescan, andreopen_spent_is latched from the first one.poll()succeeds and callsrescan_done(true)(src/alsa_sink.cpp:725), which takes the same branch as running out of attempts:if (recovered || rescan_attempts_ >= SINK_RESCAN_ATTEMPTS) { rescan_spent_ = true; }(src/sink_recovery.cpp:64).reopen_due(), findsreopen_spent_, and escalates;escalate_()(src/sink_recovery.cpp:103) only owes a rescanif (!rescan_spent_ && !rescan_in_flight_), andrescan_spent_is now true. Nothing is owed,pending()is false, andpoll()returns on its fast path forever.reset()is reached only fromconfigure()(src/alsa_sink.cpp:484and:497), so only a new stream clears any of it.Because step 1 always burns the reopen unmade, step 2 always runs, so this is deterministic rather than intermittent: the second outage in any configured stream cannot recover, and the four remaining attempts of
SINK_RESCAN_ATTEMPTS = 5are unreachable.Two smaller things fall out of the same reading:
reopen_done(true)carries the comment "The rescan stays in hand for the next outage" — exactly the intent that is missing one tier down — but it has no caller, since the only call site passesfalse.src/sink_recovery.h:35documents the contract as "Budget is per configured stream: one reopen, then up to SINK_RESCAN_ATTEMPTS rescans", which reads as five rescans available across the stream. In practice one success consumes all of them.Suggestion
Have a successful rescan return the budget instead of latching it: in
rescan_done(recovered), clearrescan_spent_and zerorescan_attempts_on success, and keep the latch for exhaustion only. Each outage then gets its own escalation ladder with the delays starting over, while a device that is genuinely gone still gives up after five tries.Whether the reopen tier should also come back on success is a separate call — it is spent unmade today, so on its own it changes nothing.
This is not a regression from #55; the same
rescan_done(true)path is in v0.1.6.Relation to #63
#63 is about what happens after a recovery: the lookahead the sink swallowed during the outage, the single sync error reported at reopen, and the silence until the timeline catches up. This one is about there being no recovery at all the second time — the sink never reopens, so nothing reaches the sync task to report. They meet in the same
poll()path but are independent: pacing the discard would not give a second outage its attempts back, and returning the budget would not shorten the gap after the first one.Environment
mainate980128, behaviour verified against PR #55 headc5b5279(sendspin-cppv0.8.0)ramips/mt7621(MIPS32 little-endian, musl), kernel 6.12.94-o hw:0,0 --buffer-ms 100(4800-frame ring, 960-frame period)S16_LE2 ch 48000 Hz onlyHappy to test a patch on the same hardware — the replug is a physical one, so this path gets exercised for real rather than simulated.