SoLoud: dispatch voice-ended callbacks off the audio thread - #20
Merged
Conversation
Fixes a lock-order inversion that deadlocks the engine during ordinary
playback.
stopVoice_internal() runs with the audio mutex held -- it asserts
mInsideAudioThreadMutex -- and called _voiceEndedCallback directly from
there. That callback reaches back into Player state: bindings.cpp's
voiceEndedCallback() calls findByHandle() and removeHandle(), both of which
take sounds_mutex. So the audio thread acquires:
audio mutex -> sounds_mutex
while Player::disposeSound() holds sounds_mutex across soloud.stop(),
acquiring:
sounds_mutex -> audio mutex
Disposing a sound while another voice ends naturally deadlocks both threads.
The audio thread strands the audio mutex, so the device produces nothing and
every later SoLoud call blocks forever -- including deinit(), and including
any synchronous call from the UI isolate, which freezes the app. Handles and
sources still look valid from Dart throughout.
For an app that swaps sounds in and out during playback this is a routine
user action, not an edge case.
Ended voices are now queued in mEndedVoiceQueue and dispatched by
unlockAudioMutex_internal() after the mutex is released. That is the single
choke point every unlock path goes through, so no call site changes. The
drain is an out-of-line slow path, keeping a small stack frame on the audio
thread for the common empty-queue case. Pending handles are copied out and
the queue cleared before the unlock, so another thread cannot corrupt the
dispatch or have its own work consumed. _voiceEndedCallback becomes atomic,
matching the other cross-thread callbacks.
Note this is not fixed by clearing callback registrations: voiceEndedCallback
takes sounds_mutex before it ever checks whether a Dart callback is
registered.
Verified with a test driving the real path -- play(), mix(), stop() on the
null backend -- whose callback re-enters SoLoud the way the real one reaches
into Player. It deadlocks against the pre-change code (killed at 15s) and
passes now. A unit test additionally asserts dispatch happens with the mutex
released, in order, re-entrant-safe, and drained when the callback is null.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a lock-order inversion that deadlocks the engine during ordinary playback. This is the highest-severity item in the series.
stopVoice_internal()runs with the audio mutex held — it assertsmInsideAudioThreadMutex— and called_voiceEndedCallbackdirectly from there. That callback reaches back intoPlayerstate:bindings.cpp'svoiceEndedCallback()callsfindByHandle()andremoveHandle(), both of which takesounds_mutex.So the audio thread acquires:
while
Player::disposeSound()holdssounds_mutexacrosssoloud.stop(), acquiring:Disposing a sound while another voice ends naturally deadlocks both threads. The audio thread strands the audio mutex, so the device produces nothing and every later SoLoud call blocks forever — including
deinit(), and including any synchronous call from the UI isolate, which freezes the app. Handles and sources still look valid from Dart throughout.For an app that swaps sounds in and out during playback this is a routine user action, not an edge case.
Relationship to #18
This is a third, independent deadlock. #18 fixed a hold-and-join cycle between
dispose()and the scheduler over the callback mutex, and fixed lost lifecycle requests. Neither touches this one: #18 leftsrc/soloud/entirely untouched, andfindByHandle()'ssounds_mutexacquisition was already the first statement invoiceEndedCallback()both before and after.The two also differ in every respect — #18's fires during teardown, this one during steady-state playback.
Note this is not fixed by clearing callback registrations at detach either:
voiceEndedCallbacktakessounds_mutexbefore it ever checks whether a Dart callback is registered.Approach
Ended voices are queued in
mEndedVoiceQueueand dispatched byunlockAudioMutex_internal()after the mutex is released. That is the single choke point every unlock path goes through, so there are no call-site changes._voiceEndedCallbackbecomes atomic, matching the other cross-thread callbacks.Verification
A test drives the real path —
play(),mix(),stop()on the null backend — with a callback that re-enters SoLoud the way the real one reaches intoPlayer:A unit test additionally asserts dispatch happens with the mutex released, in order, re-entrant-safe, and drained when the callback is null.
flutter analyzeandflutter testunchanged.Independence
Touches only
src/soloud/include/soloud.h,src/soloud/src/core/soloud.cpp,src/soloud/src/core/soloud_core_voiceops.cpp. Mergeable in any order relative to the other PRs in this series.Generated by Claude Code