fix(coreaudio): honour the full timeout when waiting for a sample rate change - #1348
fix(coreaudio): honour the full timeout when waiting for a sample rate change#1348dylanpulver wants to merge 1 commit into
Conversation
roderickvd
left a comment
There was a problem hiding this comment.
Thanks for your PR for what indeed is a real bug. I propose another solution though, consistent with what we do in ALSA.
| - **ASIO**: Fix loading a driver while a previous driver was still unloading. | ||
| - **AudioWorklet**: Fix processor construction failures not being reported to `error_callback`. | ||
| - **AudioWorklet**: Fix dropouts in output streams when the callback buffer grows. | ||
| - **CoreAudio**: A sample rate change no longer gives up early when the device reports other rates first; the caller's timeout is now honoured in full. |
There was a problem hiding this comment.
Shorter & sweeter would be:
CoreAudio: Fix sample rate changes timing out early when the device reports other rates first.
| /// A listener can report rates other than the target before it reports the new one, e.g. a | ||
| /// device stepping through rates. The whole timeout must remain available across those. | ||
| #[test] | ||
| fn wait_for_rate_honours_the_full_timeout_across_repeated_events() { |
There was a problem hiding this comment.
Please don't wait 500 ms in every test run.
| let mut remaining = timeout; | ||
|
|
||
| loop { | ||
| match receiver.recv_timeout(remaining) { |
There was a problem hiding this comment.
This still manually implements the deadline by subtracting every loop iteration. In ALSA we've got something prettier by computing one deadline up front and checking saturating_duration_since each iteration:
fn wait_for_rate(
receiver: &Receiver<f64>,
target_sample_rate: SampleRate,
timeout: Duration,
) -> Result<(), Error> {
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(Error::with_message(
ErrorKind::DeviceNotAvailable,
"Sample rate update timed out",
));
}
match receiver.recv_timeout(remaining) {
Ok(reported_rate) => {
if (reported_rate - target_sample_rate as f64).abs() < 1.0 {
return Ok(());
}
}
Err(RecvTimeoutError::Timeout) => {
return Err(Error::with_message(
ErrorKind::DeviceNotAvailable,
"Sample rate update timed out",
));
}
Err(RecvTimeoutError::Disconnected) => {
return Err(Error::with_message(
ErrorKind::StreamInvalidated,
"Sample rate listener disconnected unexpectedly",
));
}
}
}
}As bonus, this also gets rid of a blocking recv_timeout(ZERO) edge case.
|
@dylanpulver friendly nudge when you'd have the bandwidth to take this on? |
…e change The rate listener can report other rates before the requested one, e.g. a device stepping through its supported rates. The wait recomputed its remaining time by subtracting the total elapsed time from the value it had just used, so each intervening notification shortened the budget again and the wait gave up well before the caller's timeout. Compute one deadline up front and derive the remaining time from it with saturating_duration_since, matching how the ALSA host does it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M5uTyCU4WcNTsPvGrErDo
4f3e1f9 to
af036eb
Compare
|
Thanks for the nudge, and sorry for the wait. All three taken, and rebased on master (the only conflict was the changelog).
Still pinned to the bug rather than passing either way: restoring the old per-iteration subtraction fails the timeout test with |
roderickvd
left a comment
There was a problem hiding this comment.
Thanks, almost right, see below.
| target_sample_rate: SampleRate, | ||
| timeout: Duration, | ||
| ) -> Result<(), Error> { | ||
| let deadline = Instant::now() + timeout; |
There was a problem hiding this comment.
This panics on overflow. Consider:
fn wait_for_rate(
receiver: &Receiver<f64>,
target_sample_rate: SampleRate,
timeout: Option<Duration>,
) -> Result<(), Error> {
let deadline = timeout.and_then(|t| Instant::now().checked_add(t));
loop {
let received = match deadline {
Some(deadline) => {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(Error::with_message(
ErrorKind::DeviceNotAvailable,
"Sample rate update timed out",
));
}
receiver.recv_timeout(remaining)
}
None => receiver.recv().map_err(|_| RecvTimeoutError::Disconnected),
};
match received {
// ...
}
}
}| ErrorKind::StreamInvalidated, | ||
| "Sample rate listener disconnected unexpectedly", | ||
| )); | ||
| // otherwise default to 1 second. |
There was a problem hiding this comment.
Pass on the Option; the contract is that a timeout of None is to wait indefinitely.
set_sample_ratewaits forRateListenerto report the new rate, and re-arms when a notification carries some other rate. The re-arm subtractedstart.elapsed()— the total elapsed time — from the already-reducedremaining, so the budget shrank super-linearly and hit zero well before the caller's timeout:With a 500 ms timeout and a listener emitting a non-matching rate every few ms, the loop gave up after 82 ms and returned
Sample rate update timed outon a device that was still settling. Perversely, the chattier the device, the shorter the effective timeout. Subtracting from the originaltimeoutinstead makes it a single deadline; the same measurement then runs the full 500 ms.Non-matching notifications are reachable:
RateListener's callback re-reads the property and sends whatever it gets, including0.0when that read fails — which matches no target rate.The loop is extracted into
wait_for_rateso it can be tested without a device. Two tests: one asserts the whole timeout is spent across repeated non-matching events (fails at 82 ms on the current code, and also on asaturating_subvariant of it, so it pins the accounting rather than the helper); one asserts it returns as soon as the target rate arrives.cargo teston macOS goes 18 → 20 passing, including the existing device tests. fmt,clippy --all --all-targets -- -D warningsandcargo docare clean.The other backends call
recv_timeout(dur)once and never re-arm, so this is CoreAudio-only.Honest limit: I could not reproduce the intermediate-rate notification on real hardware — the built-in mic and speakers here settle in a single notification — so that half is unverified. The evidence is the extracted unit test and the arithmetic.