Fix nested ASIO streams causing deadlock on drop - #1313
Conversation
0940101 to
4cdffb9
Compare
|
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. |
|
|
|
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 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. |
|
Like I noted in my other message, dropping a stream within the callback is already an obvious logic error, since it violates realtime guarantees. I'd opt for maybe an assert to catch that case in an as-obvious-as-possible way, until the new API is here. |
|
OK, can use your inputs here. Here's my train of thought: With @LastExceed's reproducible example we've established that dropping a stream within another stream causes a deadlock, and that 6f98225 fixes it. We all agree that it's crufty. @edwloef's point about RT-safety is correct: dropping a stream isn't RT-safe, nor is allocating a What I'm then wondering is:
What we allow or don't allow by contract seems pivotal. If we allow all this, this class of reentrancy cases we need to prepare for. The cruft would be necessary. If we don't allow this, beyond documenting and asserting that, we could keep the reentrancy check but leak the entry instead of hanging. This would skip the removal, but leave a callback that keeps firing until the driver eventually tears down. I don't see how #367 would contribute to a more architecturally sound solution, but implementing duplex support for ASIO (on top of #1229) would. It wouldn't fix reentrancy with nested streams, but it wouldn't need them to pair an input with an output stream, which again is an assumption of mine to be the common case. Then again, pairing multiple streams doesn't require nesting in the first place: they could be run side-by-side. But before forbidding nested streams by contract, a nagging thought: this class of reentrancy is likely ASIO-only. Forbidding seems a bigger hammer than the bug calls for. So... keep 6f98225 or what do you guys think? @edwloef what is your need for nesting streams? |
I'd say yes, and that it renders the rest of the conversion obsolete
Forget that part, I got a few things mixed up in my head
with the duplex API obsoleting the input+output usecase, I think we should just remove the ability to have multiple ASIO streams full stop, as it is a massive hack that misrepresents the device's capabilities.
drop it, it will soon be obsolete anyway. Also I think we're massively overthinking this lol
on Discord they said this:
There is a good chance that there isn't any reasonable use case for this |
That's not entirely true, for example when you want the input and output to be different devices. Unless the duplex API has recently changed to include that use-case? |
|
That scenario is already unsupported even right now, as the ASIO SDK can only load 1 driver at a time. I guess I should have specified that I specifically mean removing the ability of having multiple streams on the same ASIO device, we just so happen to already not support multiple ASIO devices (#1299 would fix that though). |
|
How would #1299 respond to nested streams? |
|
Nesting a stream from a different device is trivial, as devices are completely independent of each other. There is no global state. Nesting a stream from the same device is impossible, as #1299 only allows 1 stream per device |
|
The different perspectives seem to converge in the direction to drop 6f98225 and replace it with one that more clearly documents that callbacks should be RT-safe and non-reentrant. The other fixes can then remain as they cost little. Let me know if there are other views. |
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.
load_driver's DriverAlreadyExists path dropped the upgraded Arc while still holding the loaded_driver guard. If the last other handle went away between the upgrade and the return, that drop runs DriverInner::drop, which takes the same lock and hangs the thread.
remove_event_callback had the same shape remove_callback just lost: retain dropped the callback in place while holding DRIVER_EVENT_CALLBACKS, so a callback owning another stream would deadlock on the same lock.
destroy_inner took loaded_driver and then BUFFER_CALLBACK, while buffer_switch_time_info holds BUFFER_CALLBACK across the user callbacks and those can reach load_driver. The guard only needs to cover ASIOExit, so the clear moves out from under it.
destroy_inner returned early on a failed ASIOExit and left the callbacks registered.
a31bbe6 to
643ca9d
Compare
|
Rebased, dropped 6f98225 and force-pushed. Tearing down an ASIO stream from inside another ASIO's stream will now deadlock again, but @edwloef's actual use case (output stream owning the input handle, both dropped from app code) remains fixed by a0601d3. Instead, I added e134fcc stating the requirement that the callback needs to be RT-safe and non-reentrant, plus another round of fixes for clearing stream callbacks in 31a500d and 643ca9d. @LastExceed could you do a final round of testing? |
Reported by @edwloef (on Discord):
A curious configuration, but alas. Fixing it was a simple as changing the drop order.