Skip to content

SoLoud: dispatch voice-ended callbacks off the audio thread - #20

Merged
Colton127 merged 2 commits into
mainfrom
fix/voice-ended-off-audio-thread
Jul 26, 2026
Merged

SoLoud: dispatch voice-ended callbacks off the audio thread#20
Colton127 merged 2 commits into
mainfrom
fix/voice-ended-off-audio-thread

Conversation

@Colton127

Copy link
Copy Markdown
Owner

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

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 left src/soloud/ entirely untouched, and findByHandle()'s sounds_mutex acquisition was already the first statement in voiceEndedCallback() 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: voiceEndedCallback takes sounds_mutex before it ever checks whether a Dart callback is registered.

Approach

Ended voices are 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 there are 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 that acquires the mutex cannot corrupt the dispatch or have its own work consumed.
  • _voiceEndedCallback becomes 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 into Player:

  • against the pre-change code it deadlocks (killed at 15 s)
  • against this change it passes

A unit test additionally asserts dispatch happens with the mutex released, in order, re-entrant-safe, and drained when the callback is null.

flutter analyze and flutter test unchanged.

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

claude and others added 2 commits July 25, 2026 04:28
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>
@Colton127
Colton127 merged commit efcf2f9 into main Jul 26, 2026
1 check passed
@Colton127
Colton127 deleted the fix/voice-ended-off-audio-thread branch July 26, 2026 01:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants