Skip to content

Fix nested ASIO streams causing deadlock on drop - #1313

Open
LastExceed wants to merge 6 commits into
RustAudio:masterfrom
LastExceed:asio-nested-streams-deadlock
Open

Fix nested ASIO streams causing deadlock on drop#1313
LastExceed wants to merge 6 commits into
RustAudio:masterfrom
LastExceed:asio-nested-streams-deadlock

Conversation

@LastExceed

@LastExceed LastExceed commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Reported by @edwloef (on Discord):

It seems #1297 may have introduced a deadlock. When one stream's callback owns another stream, and that stream is dropped, they try to re-entrantly lock the same lock in the Drop impl and everything dies.

A curious configuration, but alas. Fixing it was a simple as changing the drop order.

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.
@roderickvd

Copy link
Copy Markdown
Member

It's great that you and @edwloef showed this works. Reviewing it though, I see that swapping retain for swap_remove breaks the ordering that add_callback relies on for IDs, so two streams can end up sharing an ID after a removal.

Going through the stream lifecycle, when one callback holding a nested stream, I think there's more issues:

  • destroy_inner was clearing callbacks in place while still holding the lock
  • a callback dropping a nested stream mid-run could still deadlock in buffer_switch_time_info
  • load_driver could start ASIOInit while an old driver's ASIOExit was still running

I'd appreciate your local testing on this.

@edwloef

edwloef commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

6f98225 looks like it introduces a fair bit of non-local complexity for questionable gain: dropping a Stream is realtime-unsafe (I assume?), and should therefore not be happening from within a data callback anyways. Additionally, by pushing into a Vec, avoiding the deadlock is realtime-unsafe as well. I can't test whether it works since I don't have access to a Windows computer, but from an outside POV it seems not worth it to me. Avoiding the deadlock is great and all, but this seems like a situation where failing loudly would be appropriate.

@roderickvd

Copy link
Copy Markdown
Member

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.

@LastExceed

Copy link
Copy Markdown
Contributor Author

What exactly do you want me to test?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants