@@ -2485,3 +2485,45 @@ hand-entered address is neither, so it went.
24852485 credential-redaction check went with them.
24862486- ** ` SENDSPIN_SERVER_URL ` is answered only for the server_id the dial chose.** ` LastDial ` has
24872487 no "literal URL, taken at its word" case left; a dial with no id answers nothing.
2488+
2489+ ### 28. A second device outage in one stream never recovers — * shipped (hardware pass still owed)*
2490+
2491+ Found by item 27's CoreAudio hardware pass — two unplugs in one run, the first back in 190 ms,
2492+ the second never — but it belongs to item 14 and sits in the shared ` SinkRecovery ` , so every
2493+ device-backed sink had it. ** A rescan that succeeded retired the budget instead of restoring
2494+ it** : ` rescan_done() ` sent a recovered rescan and an exhausted one down the same branch, and
2495+ ` reopen_spent_ ` was still set from the first outage. On the second, ` reopen_due() ` declined,
2496+ ` escalate_() ` found the rescan spent, and the sink discarded until the next track with no log
2497+ line at all.
2498+
2499+ ** Shipped** in ` src/sink_recovery.{h,cpp} ` , its callers (` src/alsa_sink.cpp ` ,
2500+ ` src/pulse_sink.cpp ` , ` src/pipewire_sink.cpp ` , ` src/portaudio_sink.cpp ` ) and
2501+ ` tests/sink_recovery_test.cpp ` :
2502+
2503+ - ** The budget is per outage, not per stream.** ` rescan_done(true) ` now refills it — the
2504+ in-place reopen and the whole rescan ladder, back to ` SINK_RESCAN_DELAY_MS ` — the same intent
2505+ ` reopen_done(true) ` already had for its own path.
2506+ - ** The outage gap survives the refill.** Only the attempt bookkeeping refills; the
2507+ discarded-frame count is not ` reset() ` 's to drop here, since the first timed write still owes
2508+ the player that gap, and a device that dies again before one adds to it.
2509+ - ** A device that never comes back still gives up.** Within one outage the budget is unchanged —
2510+ one reopen, then ` SINK_RESCAN_ATTEMPTS ` rescans on the doubling delay — and only a real
2511+ recovery refills it.
2512+ - ** ` rescan_abandoned() ` for an attempt that did not recover anything.** The shutdown paths in
2513+ ALSA, PulseAudio and PipeWire used to report ` rescan_done(true) ` when ` stop() ` landed
2514+ mid-attempt, which would now refill; they abandon instead. PortAudio used to report ` true ` up
2515+ front whatever happened; it now abandons on each failure and reports ` true ` only when the
2516+ stream is really back, so its rebuild stays one-shot per outage.
2517+
2518+ ** A flapping device is not given a floor of its own.** One that recovers and dies again,
2519+ repeatedly, now keeps recovering — which is what it is doing — and the loop is already paced:
2520+ each outage spends the in-place reopen at most once, the next one has to wait out
2521+ ` SINK_RESCAN_DELAY_MS ` before its rescan, and a refill never shortens that below the base delay.
2522+ So the main loop pays at most one rescan per two seconds whatever the device does. A cap on
2523+ recoveries per stream would turn a flaky cable back into the silence this item removes.
2524+
2525+ The regression tests (` ASecondOutageInTheSameStreamRecovers ` ,
2526+ ` EveryOutageAfterARecoveryStillGivesUp ` , ` TheDiscardedGapSurvivesTheRefillIntoTheNextOutage ` )
2527+ fail against the old helper and pass against the new one. ** Still owed:** two unplug/replug
2528+ cycles in one stream on real hardware — ALSA first, since it has the most users, then CoreAudio
2529+ with item 27's harness.
0 commit comments