Fix nested ASIO streams causing deadlock on drop - #1313
Conversation
0940101 to
4cdffb9
Compare
swap_remove reorders the vec, breaking add_callback's bc.last().id + 1 scheme and causing duplicate BufferCallbackIds. remove preserves order while still dropping the removed callback after the lock is released.
bcs.clear() dropped every registered callback in place while still holding the lock. If a callback owned another stream, dropping it here would reenter remove_callback and deadlock on the same lock.
Weak::upgrade() in load_driver returns None as soon as the old DriverInner's Arc strong count hits zero, which happens before DriverInner::drop (and therefore ASIOExit) has finished running. A second thread could then start ASIOInit before the old driver's ASIOExit had returned. Share loaded_driver's lock with DriverInner so destroy_inner holds it across ASIOExit, same as load_driver already does across ASIOInit.
buffer_switch_time_info held BUFFER_CALLBACK across running every registered callback. If a callback synchronously dropped a Stream it owned, that Stream's Drop would call remove_callback and try to re-lock BUFFER_CALLBACK on the same thread, deadlocking on the ASIO real-time callback thread. Track whether the current thread is inside buffer_switch_time_info via a thread-local flag; remove_callback checks it and, if set, defers the removal into a thread-local queue instead of blocking on the lock. buffer_switch_time_info drains that queue after running callbacks, still under the lock, before releasing it.
|
It's great that you and @edwloef showed this works. Reviewing it though, I see that swapping Going through the stream lifecycle, when one callback holding a nested stream, I think there's more issues:
I'd appreciate your local testing on this. |
…r-load race fixes
|
|
|
Yeah I wouldn't mind removing it either. Let me know if you or @LastExceed not running into that when you tear down mid-callback. |
|
What exactly do you want me to test? |
6f98225: try to drop a nested stream from the parent’s callback. Does it deadlock without that commit? |
|
Here is what I tried: use std::thread;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
fn main() {
let host = cpal::host_from_id(cpal::HostId::Asio).unwrap();
let device = host.devices().unwrap().nth(6).unwrap();
let config = device.default_output_config().unwrap().into();
let error_callback = |error| panic!("{error}");
let mut count_inner = 0;
let data_callback_inner = move |data: &mut [i32], _: &_| {
count_inner += 1;
println!("inner {count_inner}");
data.fill(0);
};
println!("start inner");
let inner_stream = device.build_output_stream(config, data_callback_inner, error_callback, None).unwrap();
inner_stream.start();
let mut option = Some(inner_stream);
let mut count_outer = 0;
let data_callback_outer = move |data: &mut [i32], _: &_| {
count_outer += 1;
println!("outer {count_outer}");
data.fill(0);
if count_outer == 10 {
drop(option.take());
}
};
let outer_stream = device.build_output_stream(config, data_callback_outer, error_callback, None).unwrap();
println!("start outer");
outer_stream.start();
thread::park();
}before 6f98225 it deadlocks. after 6f98225 it works fine. I'll take a look at the code in a sec |
|
Ok, I agree that 6f98225 smells. But I also think that this is kind of an inevitable consequence of trying to support concurrent streams on a backend that doesn't natively do so. This in turn is a necessary workaround for cpal's API currently being unable to properly represent ASIO's stream model (which is basically a single duplex stream per device, with an arbitrary channel constellation, and a sequential instead of interleaved data layout), and something that we should consider removing outright once we got #367 So, if we treat this as just a temporary hack that will become obsolete with the next major release anyway, then I'd vote for keeping the commit, but it is to stay permanently, then I'd vote against it. |
Reported by @edwloef (on Discord):
A curious configuration, but alas. Fixing it was a simple as changing the drop order.