Skip to content

Commit b1b29d1

Browse files
authored
Merge branch 'main' into chrisuthe/task/restore-direct-address-dialling-to-s-alongside
2 parents ce035d2 + 6a81196 commit b1b29d1

7 files changed

Lines changed: 118 additions & 14 deletions

File tree

src/alsa_sink.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -717,7 +717,7 @@ void AlsaAudioSink::poll(int64_t now_ms) {
717717
}
718718
if (this->stopping_.load()) {
719719
// stop() can land during the open above; release the device now.
720-
this->recovery_.rescan_done(true); // shutting down; there is nothing left to retry for
720+
this->recovery_.rescan_abandoned(); // shutting down; there is nothing left to retry for
721721
this->close_device_();
722722
return;
723723
}

src/pipewire_sink.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,7 @@ void PipeWireSink::poll(int64_t now_ms) {
560560
}
561561
if (this->stopping_.load()) {
562562
// stop() can land during the reconnect above; release the stream now.
563-
this->recovery_.rescan_done(true); // shutting down; there is nothing left to retry for
563+
this->recovery_.rescan_abandoned(); // shutting down; there is nothing left to retry for
564564
this->close_stream_();
565565
this->stop_loop_();
566566
return;

src/portaudio_sink.cpp

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -577,9 +577,6 @@ void PortAudioSink::poll(int64_t now_ms) {
577577
if (!this->recovery_.rescan_due(now_ms)) {
578578
return;
579579
}
580-
// Reported up front and always as recovered: a device-list rebuild is one-shot.
581-
this->recovery_.rescan_done(true);
582-
583580
const StreamFormat format = this->last_format_;
584581
// The stream goes first whatever happens next: Pa_Terminate() with one open is undefined,
585582
// and every PaDeviceIndex -- device_index_ among them, which this clears -- dies with it.
@@ -592,6 +589,7 @@ void PortAudioSink::poll(int64_t now_ms) {
592589
// PortAudio is down, so the sink is inert until it comes back.
593590
cli_log(LogLevel::ERROR, "portaudio: could not restart PortAudio to look for '%s': %s",
594591
this->name().c_str(), this->pa_.error());
592+
this->recovery_.rescan_abandoned(); // a device-list rebuild is one-shot
595593
return;
596594
}
597595

@@ -602,16 +600,20 @@ void PortAudioSink::poll(int64_t now_ms) {
602600
"portaudio: '%s' is still gone after a device rescan -- discarding until the "
603601
"next stream (%s)",
604602
this->name().c_str(), error.c_str());
603+
this->recovery_.rescan_abandoned();
605604
return;
606605
}
607606
if (!this->open_stream_(device, format.sample_rate, format.channels, format.bit_depth)) {
608-
return; // open_stream_() has already said why, once
607+
this->recovery_.rescan_abandoned(); // open_stream_() has already said why, once
608+
return;
609609
}
610610
if (this->stopping_.load()) {
611611
// stop() can land during the slow cycle above; release the device now.
612+
this->recovery_.rescan_abandoned();
612613
this->close_stream_();
613614
return;
614615
}
616+
this->recovery_.rescan_done(true);
615617
// Name the device found: a rescan can renumber indices.
616618
const PaDeviceInfo* info = Pa_GetDeviceInfo(device);
617619
cli_log(LogLevel::INFO, "portaudio: '%s' is back after a device rescan, on '%s'",

src/pulse_sink.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -609,7 +609,7 @@ void PulseAudioSink::poll(int64_t now_ms) {
609609
}
610610
if (this->stopping_.load()) {
611611
// stop() can land during the reconnect above; release the stream now.
612-
this->recovery_.rescan_done(true); // shutting down; there is nothing left to retry for
612+
this->recovery_.rescan_abandoned(); // shutting down; there is nothing left to retry for
613613
this->close_stream_();
614614
return;
615615
}

src/sink_recovery.cpp

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,12 @@ void SinkRecovery::rescan_done(bool recovered) {
6161
return;
6262
}
6363
this->rescan_in_flight_ = false;
64-
if (recovered || this->rescan_attempts_ >= SINK_RESCAN_ATTEMPTS) {
64+
if (recovered) {
65+
// The gap stays owed: only the next timed write can retire it.
66+
this->refill_();
67+
return;
68+
}
69+
if (this->rescan_attempts_ >= SINK_RESCAN_ATTEMPTS) {
6570
this->rescan_spent_ = true;
6671
this->rescan_owed_.store(false, std::memory_order_relaxed);
6772
return;
@@ -71,6 +76,14 @@ void SinkRecovery::rescan_done(bool recovered) {
7176
this->rescan_owed_.store(true, std::memory_order_relaxed);
7277
}
7378

79+
void SinkRecovery::rescan_abandoned() {
80+
if (!this->rescan_in_flight_) {
81+
return;
82+
}
83+
this->rescan_in_flight_ = false;
84+
this->rescan_spent_ = true;
85+
}
86+
7487
bool SinkRecovery::pending() const {
7588
return this->rescan_owed_.load(std::memory_order_relaxed);
7689
}
@@ -91,13 +104,17 @@ void SinkRecovery::forget_discarded_frames() {
91104
}
92105

93106
void SinkRecovery::reset() {
107+
this->refill_();
108+
this->discarded_frames_ = 0;
109+
}
110+
111+
void SinkRecovery::refill_() {
94112
this->reopen_spent_ = false;
95113
this->rescan_spent_ = false;
96114
this->rescan_in_flight_ = false;
97115
this->rescan_attempts_ = 0;
98116
this->rescan_owed_.store(false, std::memory_order_relaxed);
99117
this->rescan_at_ms_ = NOT_STAMPED;
100-
this->discarded_frames_ = 0;
101118
}
102119

103120
void SinkRecovery::escalate_() {

src/sink_recovery.h

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,16 @@ inline constexpr int64_t SINK_RESCAN_DELAY_MS = 2000;
2828
/// Ceiling the doubling delay between retried rescans grows to.
2929
inline constexpr int64_t SINK_RESCAN_MAX_DELAY_MS = 30000;
3030

31-
/// Rescan attempts allowed per configured stream.
31+
/// Rescan attempts allowed before the budget is refilled.
3232
inline constexpr int SINK_RESCAN_ATTEMPTS = 5;
3333

3434
/// Decides when a sink reopens a dead device in place and when it rescans or reconnects.
35-
/// Budget is per configured stream: one reopen, then up to SINK_RESCAN_ATTEMPTS rescans.
35+
/// Budget: one reopen, then up to SINK_RESCAN_ATTEMPTS rescans. Only a recovered rescan or a newly
36+
/// configured stream refills it; a recovered reopen leaves the next outage the rescans alone.
3637
/// Every method but pending() must be called under the lock that serialises the sink's stream.
3738
class SinkRecovery {
3839
public:
39-
/// Whether write() should reopen the device in place; true at most once per stream.
40+
/// Whether write() should reopen the device in place; true at most once per refill.
4041
/// @return true if the caller should reopen now and report to reopen_done().
4142
bool reopen_due();
4243

@@ -48,10 +49,14 @@ class SinkRecovery {
4849
/// @return true once the delay is up; again only after rescan_done(false).
4950
bool rescan_due(int64_t now_ms);
5051

51-
/// Records the second attempt's outcome; a one-shot backend reports true regardless.
52+
/// Records the second attempt's outcome; a recovery refills the budget for the next outage.
5253
/// A report with no attempt outstanding does nothing.
5354
void rescan_done(bool recovered);
5455

56+
/// Ends the outstanding attempt with nothing refilled and nothing more owed: a shutdown, or a
57+
/// one-shot backend's failure. A call with no attempt outstanding does nothing.
58+
void rescan_abandoned();
59+
5560
/// True while a rescan is still owed. The one method safe to call without the lock.
5661
bool pending() const;
5762

@@ -70,6 +75,8 @@ class SinkRecovery {
7075

7176
private:
7277
void escalate_();
78+
/// Refills the attempt budget, leaving the discarded-frame count alone.
79+
void refill_();
7380

7481
static constexpr int64_t NOT_STAMPED = INT64_MIN;
7582

tests/sink_recovery_test.cpp

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ TEST(SinkRecovery, ACallerThatReportsNothingGetsExactlyOneRescan) {
233233
escalate(recovery);
234234
ASSERT_GE(rescan_fires_at(recovery, T0, T0 + 10 * SINK_RESCAN_DELAY_MS), T0);
235235

236-
// A caller that never reports gets exactly one rescan, as PortAudioSink relies on.
236+
// A caller that never reports gets exactly one rescan.
237237
EXPECT_FALSE(recovery.reopen_due());
238238
EXPECT_FALSE(recovery.pending());
239239
EXPECT_EQ(rescan_fires_at(recovery, T0, T0 + 100 * SINK_RESCAN_DELAY_MS), -1);
@@ -251,6 +251,84 @@ TEST(SinkRecovery, ASuccessfulRescanIsNotRetried) {
251251
EXPECT_EQ(rescan_fires_at(recovery, fired_at, fired_at + 100 * SINK_RESCAN_DELAY_MS), -1);
252252
}
253253

254+
TEST(SinkRecovery, ASecondOutageInTheSameStreamRecovers) {
255+
SinkRecovery recovery;
256+
escalate(recovery);
257+
int64_t now = rescan_fires_at(recovery, T0, T0 + 10 * SINK_RESCAN_DELAY_MS);
258+
ASSERT_GT(now, 0);
259+
recovery.rescan_done(false);
260+
now = rescan_fires_at(recovery, now, now + 10 * SINK_RESCAN_DELAY_MS);
261+
ASSERT_GT(now, 0);
262+
recovery.rescan_done(true);
263+
264+
// The device died again: the reopen is back in hand, and so is a fresh ladder.
265+
escalate(recovery);
266+
EXPECT_TRUE(recovery.pending());
267+
EXPECT_EQ(rescan_fires_at(recovery, now, now + 10 * SINK_RESCAN_DELAY_MS),
268+
now + SINK_RESCAN_DELAY_MS);
269+
}
270+
271+
TEST(SinkRecovery, EveryOutageAfterARecoveryStillGivesUp) {
272+
SinkRecovery recovery;
273+
escalate(recovery);
274+
int64_t now = rescan_fires_at(recovery, T0, T0 + 10 * SINK_RESCAN_DELAY_MS);
275+
ASSERT_GT(now, 0);
276+
recovery.rescan_done(true);
277+
278+
escalate(recovery);
279+
for (int attempt = 0; attempt < SINK_RESCAN_ATTEMPTS; ++attempt) {
280+
now = rescan_fires_at(recovery, now, now + 100 * SINK_RESCAN_DELAY_MS);
281+
ASSERT_GT(now, 0) << "attempt " << attempt << " never fired";
282+
recovery.rescan_done(false);
283+
}
284+
285+
EXPECT_FALSE(recovery.pending());
286+
EXPECT_FALSE(recovery.reopen_due());
287+
EXPECT_EQ(rescan_fires_at(recovery, now, now + 100 * SINK_RESCAN_DELAY_MS), -1);
288+
}
289+
290+
TEST(SinkRecovery, TheDiscardedGapSurvivesTheRefillIntoTheNextOutage) {
291+
SinkRecovery recovery;
292+
escalate(recovery);
293+
recovery.discard_frames(48'000U);
294+
ASSERT_GE(rescan_fires_at(recovery, T0, T0 + 10 * SINK_RESCAN_DELAY_MS), T0);
295+
recovery.rescan_done(true);
296+
297+
// Died again before a timed write took the first gap: both are still owed.
298+
escalate(recovery);
299+
recovery.discard_frames(1'000U);
300+
301+
EXPECT_EQ(recovery.take_discarded_frames(), 49'000U);
302+
}
303+
304+
TEST(SinkRecovery, AnAbandonedRescanRefillsNothing) {
305+
SinkRecovery recovery;
306+
escalate(recovery);
307+
const int64_t fired_at = rescan_fires_at(recovery, T0, T0 + 10 * SINK_RESCAN_DELAY_MS);
308+
ASSERT_GT(fired_at, 0);
309+
310+
recovery.rescan_abandoned();
311+
312+
EXPECT_FALSE(recovery.pending());
313+
EXPECT_FALSE(recovery.reopen_due());
314+
EXPECT_EQ(rescan_fires_at(recovery, fired_at, fired_at + 100 * SINK_RESCAN_DELAY_MS), -1);
315+
316+
// A late recovered report has no attempt to answer, so it cannot refill either.
317+
recovery.rescan_done(true);
318+
EXPECT_FALSE(recovery.reopen_due());
319+
}
320+
321+
TEST(SinkRecovery, AbandoningWithNothingOutstandingDoesNothing) {
322+
SinkRecovery recovery;
323+
escalate(recovery);
324+
325+
recovery.rescan_abandoned();
326+
327+
EXPECT_TRUE(recovery.pending());
328+
EXPECT_EQ(rescan_fires_at(recovery, T0, T0 + 10 * SINK_RESCAN_DELAY_MS),
329+
T0 + SINK_RESCAN_DELAY_MS);
330+
}
331+
254332
TEST(SinkRecovery, AFailedRescanIsTriedAgainAfterALongerDelay) {
255333
SinkRecovery recovery;
256334
escalate(recovery);

0 commit comments

Comments
 (0)