Skip to content

Commit 5365da1

Browse files
committed
Reopen on a device-list change instead of waiting out the backoff
A dying device reports itself through kAudioDevicePropertyDeviceIsAlive, but a returning one has nothing to report, so a replugged named device was found only by SinkRecovery's 2->30 s ladder. The hardware pass measured the cost: a device physically back at 21 s was not reopened until 41 s, because the ladder had already doubled past it. Listen on kAudioHardwarePropertyDevices too, and give SinkRecovery a rescan_soon() that brings an owed rescan forward to the next tick. It arms nothing that is not already owed and the attempt still counts, so a burst of plug events cannot spin.
1 parent bdc9413 commit 5365da1

6 files changed

Lines changed: 141 additions & 20 deletions

File tree

docs/ROADMAP.md

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2542,6 +2542,12 @@ frameworks, so the binary links only what every Mac already has.
25422542
directly, so there is no `Pa_Terminate()`/`Pa_Initialize()` and no index renumbering: the
25432543
listener flags the death off its HAL thread, `write()` spends the one in-place reopen, and
25442544
`poll()` retries on `SinkRecovery`'s backoff.
2545+
- **A replug is a notification too, not just a timeout.** A dying device says so itself; a
2546+
returning one cannot, so `kAudioHardwarePropertyDevices` is listened to as well and
2547+
`SinkRecovery::rescan_soon()` brings the owed rescan forward to the next tick. Without it the
2548+
backoff alone decides, and the hardware pass measured what that costs: a device physically back
2549+
at 21 s was not reopened until 41 s, because the 2 s ladder had already doubled past it. The
2550+
attempt still counts against the budget, so a burst of plug events cannot spin.
25452551
- **A moved system default is followed, and is not a recovery.** A bare `-o coreaudio` also
25462552
listens on `kAudioHardwarePropertyDefaultOutputDevice`; `poll()` reopens on the new device
25472553
with the ring tail accounted as an outage gap. Deliberately outside `SinkRecovery`'s budget:
@@ -2592,19 +2598,18 @@ What it proved:
25922598
player in the same group was in phase**, which is the only real test the DAC offset has, and the
25932599
thing item 3 shipped without.
25942600

2595-
Still open after that pass:
2596-
2597-
- **The volume ramp.** The full→half change was heard as a step or click. The arithmetic is not at
2598-
fault — `volume_ramp_step(48000)` is 4473925 Q32/frame and 100→50 is a genuine 621-frame ramp —
2599-
and mute, which is the *slowest* ramp at a full 20 ms, was reported clean. The likeliest reading
2600-
is the slew rate being fast rather than absent, since `VOLUME_RAMP_MS` is a full-scale time and
2601-
100→50 is 12.9 ms. `open_unit_()` now logs the step at `debug` so the two cases can be told
2602-
apart without ears; a second listen is owed before anything is changed, and if it is the slew
2603-
rate then it belongs to item 13, which owns it for all three backends.
2604-
- **The outage-gap figure.** The harness's own `recovery` driver restarted its pacing clock every
2605-
0.5 s, leaving the ring dry between slices — ~5000 ppm of loss and audible popping with no
2606-
outage at all, which swamped the measurement. Fixed in the harness; the number is owed from a
2607-
re-run. The sink's recovery *mechanisms* are not in doubt, only the accounting figure.
2601+
A second round settled the two things the first left open, and turned up the listener gap above:
2602+
2603+
- **The volume ramp is fine**, and the first round's "FAIL" was against a wrong expectation: 20 ms
2604+
is what a *full-scale* change takes, so 100→50 is 12.9 ms. `open_unit_()` now logs the step at
2605+
`debug` (4473925 Q32/frame at 48 kHz, non-zero, so ramping rather than snapping), and mute — the
2606+
largest change and therefore the slowest ramp — is clean. What is audible on full→half is the
2607+
slew rate, which is item 13's and shared by all three backends.
2608+
- **The outage gap is reported, not swallowed.** Across a ~10 s unplug the reported frame count
2609+
froze exactly, then jumped by 33.9 s of audio in one step on replug; of roughly 30 s of silence
2610+
only ~84 ms went permanently unaccounted, and the residual is flat rather than growing. The
2611+
first round could not measure this because the harness restarted its pacing clock every 0.5 s
2612+
and lost ~5000 ppm of its own.
26082613

26092614
Underneath the pass, CI carries the rest: the backend compiles clean under `-Werror` on the
26102615
`macos-arm64` leg, that leg's `otool -L` guard reports only CoreAudio, AudioToolbox,

src/coreaudio_sink.cpp

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,11 @@ void CoreAudioSink::poll(int64_t now_ms) {
682682
if (this->last_format_.sample_rate == 0) {
683683
return; // nothing was ever configured, so there is nothing to reopen at
684684
}
685+
// Cleared only once a rescan is owed, so a replug that lands before the escalation still
686+
// counts; a stale flag costs one early attempt and nothing else.
687+
if (this->devices_changed_.exchange(false)) {
688+
this->recovery_.rescan_soon();
689+
}
685690
if (!this->recovery_.rescan_due(now_ms)) {
686691
return;
687692
}
@@ -882,6 +887,7 @@ bool CoreAudioSink::open_unit_(AudioDeviceID device, uint32_t sample_rate, uint8
882887

883888
// Cleared before the listeners go on, so a death between the two is not lost.
884889
this->device_lost_.store(false);
890+
this->devices_changed_.store(false);
885891
this->add_listeners_(device);
886892

887893
err = AudioOutputUnitStart(unit);
@@ -1064,6 +1070,14 @@ void CoreAudioSink::add_listeners_(AudioDeviceID device) {
10641070
this->listening_default_ = true;
10651071
}
10661072
}
1073+
1074+
// A device that died tells us so itself; a device that comes back cannot, so the host's
1075+
// device list is what turns a replug into a reopen instead of a wait on the backoff.
1076+
const AudioObjectPropertyAddress devices = address_of(kAudioHardwarePropertyDevices);
1077+
if (AudioObjectAddPropertyListener(kAudioObjectSystemObject, &devices,
1078+
&CoreAudioSink::property_listener, this) == noErr) {
1079+
this->listening_devices_ = true;
1080+
}
10671081
}
10681082

10691083
void CoreAudioSink::remove_listeners_() {
@@ -1081,6 +1095,12 @@ void CoreAudioSink::remove_listeners_() {
10811095
&CoreAudioSink::property_listener, this);
10821096
this->listening_default_ = false;
10831097
}
1098+
if (this->listening_devices_) {
1099+
const AudioObjectPropertyAddress devices = address_of(kAudioHardwarePropertyDevices);
1100+
AudioObjectRemovePropertyListener(kAudioObjectSystemObject, &devices,
1101+
&CoreAudioSink::property_listener, this);
1102+
this->listening_devices_ = false;
1103+
}
10841104
}
10851105

10861106
OSStatus CoreAudioSink::property_listener(AudioObjectID /*object*/, UInt32 count,
@@ -1097,6 +1117,9 @@ OSStatus CoreAudioSink::property_listener(AudioObjectID /*object*/, UInt32 count
10971117
case kAudioHardwarePropertyDefaultOutputDevice:
10981118
self->default_moved_.store(true);
10991119
break;
1120+
case kAudioHardwarePropertyDevices:
1121+
self->devices_changed_.store(true);
1122+
break;
11001123
default:
11011124
break;
11021125
}

src/coreaudio_sink.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,13 @@ class CoreAudioSink final : public AudioSink {
148148
std::atomic<bool> device_lost_{false};
149149
/// Set by the property listener when the system default output moves; poll() clears it.
150150
std::atomic<bool> default_moved_{false};
151+
/// Set by the property listener when a device is added or removed anywhere on the host.
152+
/// Kept until a rescan is owed, so a replug during the backoff is not waited out.
153+
std::atomic<bool> devices_changed_{false};
151154
/// Whether each listener is registered, so each is removed exactly once. Guarded by mutex_.
152155
bool listening_alive_{false};
153156
bool listening_default_{false};
157+
bool listening_devices_{false};
154158
/// The device the death listener is registered on. Guarded by mutex_.
155159
AudioDeviceID listening_device_{kAudioObjectUnknown};
156160

src/sink_recovery.cpp

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,18 @@ bool SinkRecovery::rescan_due(int64_t now_ms) {
4040
if (!this->rescan_owed_.load(std::memory_order_relaxed)) {
4141
return false;
4242
}
43-
if (this->rescan_at_ms_ == NOT_STAMPED) {
44-
// Stamped on the first tick after escalation; the delay grows with attempts made.
45-
this->rescan_at_ms_ = now_ms + delay_for_(this->rescan_attempts_);
46-
return false;
47-
}
48-
if (now_ms < this->rescan_at_ms_) {
49-
return false;
43+
// The backoff is only for waiting out a device nothing has said anything about.
44+
if (!this->rescan_immediate_) {
45+
if (this->rescan_at_ms_ == NOT_STAMPED) {
46+
// Stamped on the first tick after escalation; the delay grows with attempts made.
47+
this->rescan_at_ms_ = now_ms + delay_for_(this->rescan_attempts_);
48+
return false;
49+
}
50+
if (now_ms < this->rescan_at_ms_) {
51+
return false;
52+
}
5053
}
54+
this->rescan_immediate_ = false;
5155
++this->rescan_attempts_;
5256
this->rescan_in_flight_ = true;
5357
// In flight: nothing more is owed until rescan_done() reports.
@@ -71,6 +75,13 @@ void SinkRecovery::rescan_done(bool recovered) {
7175
this->rescan_owed_.store(true, std::memory_order_relaxed);
7276
}
7377

78+
void SinkRecovery::rescan_soon() {
79+
// Not while one is in flight: it is already running, and rescan_done() re-stamps after it.
80+
if (this->rescan_owed_.load(std::memory_order_relaxed) && !this->rescan_in_flight_) {
81+
this->rescan_immediate_ = true;
82+
}
83+
}
84+
7485
bool SinkRecovery::pending() const {
7586
return this->rescan_owed_.load(std::memory_order_relaxed);
7687
}
@@ -94,6 +105,7 @@ void SinkRecovery::reset() {
94105
this->reopen_spent_ = false;
95106
this->rescan_spent_ = false;
96107
this->rescan_in_flight_ = false;
108+
this->rescan_immediate_ = false;
97109
this->rescan_attempts_ = 0;
98110
this->rescan_owed_.store(false, std::memory_order_relaxed);
99111
this->rescan_at_ms_ = NOT_STAMPED;

src/sink_recovery.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ class SinkRecovery {
5252
/// A report with no attempt outstanding does nothing.
5353
void rescan_done(bool recovered);
5454

55+
/// Brings an owed rescan forward to the next tick, for a backend whose OS has told it the
56+
/// device list changed. Never arms one that is not owed, and the attempt it releases still
57+
/// counts against the budget, so a burst of notifications stays bounded.
58+
void rescan_soon();
59+
5560
/// True while a rescan is still owed. The one method safe to call without the lock.
5661
bool pending() const;
5762

@@ -81,6 +86,8 @@ class SinkRecovery {
8186
bool rescan_spent_{false};
8287
/// Set from handing out an attempt until rescan_done(); blocks re-arming and double counting.
8388
bool rescan_in_flight_{false};
89+
/// Set by rescan_soon(); makes the next rescan_due() skip the backoff exactly once.
90+
bool rescan_immediate_{false};
8491
int rescan_attempts_{0};
8592
/// Frames accepted with no device to play them, not yet retired; see discard_frames().
8693
uint32_t discarded_frames_{0};

tests/sink_recovery_test.cpp

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,76 @@ TEST(SinkRecovery, AFailedRescanIsTriedAgainAfterALongerDelay) {
266266
EXPECT_EQ(second, first + (2 * SINK_RESCAN_DELAY_MS));
267267
}
268268

269+
// rescan_soon(): the OS saying the device list moved, instead of waiting out the backoff.
270+
271+
TEST(SinkRecovery, ADeviceListChangeFiresTheRescanWithoutWaitingOutTheDelay) {
272+
SinkRecovery recovery;
273+
escalate(recovery);
274+
275+
// Stamps the deadline, which is still a long way off.
276+
ASSERT_FALSE(recovery.rescan_due(T0));
277+
recovery.rescan_soon();
278+
279+
// The very next tick, rather than T0 + SINK_RESCAN_DELAY_MS.
280+
EXPECT_TRUE(recovery.rescan_due(T0 + 10));
281+
}
282+
283+
TEST(SinkRecovery, ADeviceListChangeSkipsTheBackoffExactlyOnce) {
284+
SinkRecovery recovery;
285+
escalate(recovery);
286+
287+
ASSERT_FALSE(recovery.rescan_due(T0));
288+
recovery.rescan_soon();
289+
ASSERT_TRUE(recovery.rescan_due(T0 + 10));
290+
recovery.rescan_done(false);
291+
292+
// The next attempt is back on the ladder: one notification buys one attempt, not a spin.
293+
const int64_t next = rescan_fires_at(recovery, T0 + 10, T0 + (100 * SINK_RESCAN_DELAY_MS));
294+
EXPECT_EQ(next, T0 + 10 + (2 * SINK_RESCAN_DELAY_MS));
295+
}
296+
297+
TEST(SinkRecovery, ADeviceListChangeArmsNothingWhenNoRescanIsOwed) {
298+
SinkRecovery recovery;
299+
300+
// Nothing has died, so there is nothing to bring forward.
301+
recovery.rescan_soon();
302+
EXPECT_FALSE(recovery.pending());
303+
EXPECT_FALSE(recovery.rescan_due(T0));
304+
EXPECT_FALSE(recovery.rescan_due(T0 + (100 * SINK_RESCAN_DELAY_MS)));
305+
}
306+
307+
TEST(SinkRecovery, ADeviceListChangeStillSpendsTheAttemptBudget) {
308+
SinkRecovery recovery;
309+
escalate(recovery);
310+
311+
// A burst of notifications must not buy unlimited attempts.
312+
int fired = 0;
313+
for (int64_t now = T0; now < T0 + (1000 * SINK_RESCAN_DELAY_MS); now += 10) {
314+
recovery.rescan_soon();
315+
if (recovery.rescan_due(now)) {
316+
++fired;
317+
recovery.rescan_done(false);
318+
}
319+
}
320+
EXPECT_EQ(fired, SINK_RESCAN_ATTEMPTS);
321+
EXPECT_FALSE(recovery.pending());
322+
}
323+
324+
TEST(SinkRecovery, ADeviceListChangeDoesNotDisturbARescanInFlight) {
325+
SinkRecovery recovery;
326+
escalate(recovery);
327+
328+
const int64_t fired = rescan_fires_at(recovery, T0, T0 + (10 * SINK_RESCAN_DELAY_MS));
329+
ASSERT_GT(fired, 0);
330+
331+
// In flight: the attempt is already running, so this must not queue a second one.
332+
recovery.rescan_soon();
333+
EXPECT_FALSE(recovery.rescan_due(fired + 10));
334+
335+
recovery.rescan_done(true);
336+
EXPECT_FALSE(recovery.pending());
337+
}
338+
269339
TEST(SinkRecovery, TheRetriesRunOutAndTheDelayStopsGrowing) {
270340
SinkRecovery recovery;
271341
escalate(recovery);

0 commit comments

Comments
 (0)