fix: changeDevice() ANR on Android — stop starving the audio thread, serialize against other device ops - #27
Merged
Colton127 merged 3 commits intoAug 7, 2026
Conversation
`miniaudio_changeDevice_impl()` held SoLoud's audio-thread mutex across the whole device swap, including the cleanup that runs when the replacement device fails to start. That cleanup calls `ma_device_uninit()`, which waits for the backend's data callback to return -- and that callback (`soloud_miniaudio_audiomixer` -> `Soloud::mix` -> `mix_internal`) blocks on the very mutex the caller is holding. The calling thread and the audio thread wait on each other forever, which on Android is an ANR with no recovery. The mutex is now released before the failure-path `ma_device_uninit()`, so the audio callback can drain, the stream closes, and the error propagates: `UNKNOWN_ERROR` -> `PlayerErrors.audioDeviceFailedToStart` -> `SoLoudAudioDeviceFailedToStartCppException`. `changeDevice()` throws instead of hanging. Why the same mutex also causes the start to fail in the first place: on the AAudio legacy (non-MMAP) path the stream only transitions from STARTING to STARTED once the first data callback has run. That callback is blocked on the audio mutex, so `ma_wait_for_simple_state_transition__aaudio()` times out after 5 seconds and `ma_device_start()` returns `MA_ERROR` (-1) -- the `ma_device_start failed with error -1` line in the log. Devices that get the MMAP/exclusive path (S26 Ultra, iPhone 11) report STARTED without waiting on a callback, never fail the start, and so never reach the deadlocking cleanup. That underlying start failure is left in place here on purpose: this commit only makes it observable rather than fatal. The failure path also resets `gDeviceStopped`, which was left `false` after a device that never ran. A subsequent `deinit()` would otherwise poll for a "stopped" notification that can no longer arrive, burning its full 500 ms timeout. This path became reachable from Dart in alnitak#532: before it, `changeDevice()` with no argument was rejected in `Player::changeDevice()` by a signed/unsigned comparison and never reached the backend at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6iyp4neA51rNqRULEKVHA
…e ops Two problems in `miniaudio_changeDevice_impl()`, both only reachable since the device change started making it to the backend in alnitak#532. The audio-thread mutex was held across the whole swap. It does not protect `gDevice` -- the data callback reaches the engine through `pDevice->pUserData` and takes that mutex itself inside `Soloud::mix()`, and `ma_device_uninit()` already guarantees the callback has stopped before it returns -- so holding it only blocks the mixer. On Android that is fatal: on AAudio's legacy (non-MMAP) path a stream reports STARTED only once its first data callback has run, so the blocked callback made `ma_device_start()` time out after 5s and the cleanup `ma_device_uninit()` then waited forever on that same blocked callback. Devices that get the MMAP path report STARTED without waiting on a callback, which is why an emulator and an LG V20 froze where an S26 Ultra and an iPhone 11 did not. The swap no longer takes that mutex. Nothing serialized the swap against the other operations on the same `gDevice` either: pause, resume and teardown. Between the uninit and the init there is no device at all, so a concurrent start/stop runs against a torn struct. It now takes `gDeviceOpsMutex`, which already existed for exactly this. Two related adjustments: `soloud_miniaudio_pause()` was the one device operation that never took that lock -- and the likeliest to collide, since Player's pause scheduler fires it from its own thread ~500ms after the last voice ends -- and the lock's declaring comment now names all four operations it covers. Empirically, on the concurrent test below, dropping just the device-ops lock segfaults on some runs and hangs on others; with it, 5/5 runs are clean. Tests: * `test/change_device_test.cpp` (+ `run_change_device_test.sh`) swaps devices under a live voice, asserts no swap blocks for the AAudio timeout, and races swaps against each other and against pause/resume. Unlike the other native tests here it needs the miniaudio backend and a real output device, so it reports SKIPPED and exits 0 where there is none. Verified to hang against the unfixed backend -- a deadlock cannot report itself, so the failure is the timeout, not a FAILED line. * `playback_devices.dart` now times every `changeDevice()` against a 2s budget (the stall that precedes the freeze), runs ten back-to-back swaps under a looping voice, and aims five more at the deferred engine-pause window. The Dart side cannot catch a true deadlock: `changeDevice()` is a synchronous FFI call on the UI isolate, so once native wedges there is no Dart left to time it out. The web WASM artifact still needs a rebuild to pick this up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6iyp4neA51rNqRULEKVHA
The mechanics of the freeze and the device-op serialization are implementation detail; what an app author needs to know is that a failed device change now throws instead of hanging. The rest lives in the pull request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6iyp4neA51rNqRULEKVHA
7 tasks
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.
Description
SoLoud.instance.changeDevice()permanently froze the app (ANR) on an Android emulator and an LG V20, with or without anewDeviceargument. An S26 Ultra and an iPhone 11 were unaffected. The last thing in the log before the freeze:Root cause
Both the
-1and the freeze come from one thing:miniaudio_changeDevice_impl()held SoLoud's audio-thread mutex across the entire device swap (soloud_miniaudio.cpp). The audio data callback —soloud_miniaudio_audiomixer→Soloud::mix→mix_internal— takes that same mutex.Why the start failed. On AAudio's legacy (non-MMAP) path,
AAudioStream_requestStartreturns OK but leaves the stream inSTARTING; it only reachesSTARTEDonce the first data callback has run. That callback was blocked on the mutex the calling thread held, soma_wait_for_simple_state_transition__aaudio()(5-second timeout,miniaudio.h) gave up andma_device_start()returnedMA_ERROR= -1.The log shows the fallback explicitly —
open() perfMode changed from 12 to 10, LOW_LATENCY demoted to NONE. The S26 Ultra and iPhone 11 keep the MMAP/exclusive path, where the stream reportsSTARTEDwithout waiting on a callback, so the start never failed there and the failure path was never reached.Why it then froze. The failure branch called
ma_device_uninit()while still holding the mutex. That waits for the data callback to return; the callback was waiting for the mutex. Permanent deadlock on the UI thread.Why it looked like a regression from alnitak#532. The deadlock predates that PR, but before it
changeDevice()with no argument was rejected inPlayer::changeDevice()by a signed/unsigned comparison and never reached the backend at all. alnitak#532 also added the test that exercises this path.Second bug found while fixing the first
Nothing serialized the swap against the other operations on the same
gDevice:soloud_miniaudio_pause(),soloud_miniaudio_resume()andsoloud_miniaudio_deinit(). Between the uninit and the init there is no device at all, so a concurrent start/stop runs against a torn struct — the SIGABRT already documented in the comment insidesoloud_miniaudio_resume().This is not lifecycle-only.
Player::pauseEngineScheduler()runs on its own thread and callsSoloud::pause()~500 ms after the last voice ends (kPauseEngineDelayMs), so an ordinary stop-then-switch can collide with no OS event involved.The fix
The audio mutex is no longer held across the swap. It never protected
gDevice: the data callback reaches the engine throughpDevice->pUserDataand takes the mutex itself insideSoloud::mix(), andma_device_uninit()already guarantees the callback has stopped before it returns. Holding it only blocked the mixer, which is exactly what broke the AAudio start handshake.The swap now takes
gDeviceOpsMutex, which already existed for this purpose. Two related adjustments:soloud_miniaudio_pause()was the one device operation that never took that lock — and the likeliest to collide, given the pause scheduler above. It takes it now, and returns early when there is no initialized device.Lock ordering was checked:
gDeviceOpsMutex→ miniaudio's internalrerouteLockin every path, no inversion. Nothing re-enters throughon_notification— the state-changed callback posts to a Dart port rather than calling back synchronously.A device change that still fails now returns
audioDeviceFailedToStart→SoLoudAudioDeviceFailedToStartCppExceptionrather than hanging. Note the engine stays initialized but is left without an output device — the failure path uninits the half-started device and does not restore the previous one. Recovering requiresdeinit()/init().Two smaller things on that same path: the
ma_device_initfailure branch now logs like itsma_device_startsibling, and the failure path resetsgDeviceStopped, which was leftfalsefor a device that never ran — a followingdeinit()would otherwise burn its full 500 ms polling for a "stopped" notification that can no longer arrive.Was a lock actually needed?
Tested rather than assumed. With the device-ops lock removed from the swap (audio-mutex fix still in place), the concurrent test segfaults on some runs and hangs on others. With it, 5/5 runs are clean. Stop-before-uninit ordering alone is not sufficient. The serial path is unaffected either way — 30 swaps, slowest 46 ms.
Tests
test/change_device_test.cpp+run_change_device_test.sh(new, matching the existing native-test convention intest/). Swaps devices under a live voice, asserts no swap blocks for the AAudio state-transition timeout, and races swaps against each other and against pause/resume. Verified to hang against the unfixed backend — a deadlock cannot report itself, so the failure there is the timeout, not aFAILEDline. Unlike the other native tests it needs the miniaudio backend and a real output device, so it printsSKIPPEDand exits 0 where there is none.example/tests/tests/playback_devices.dartnow times everychangeDevice()against a 2-second budget (the stall that precedes the freeze), runs ten back-to-back swaps under a looping voice asserting the voice survives, and aims five more at the deferred engine-pause window.One limit worth stating: the Dart side cannot catch a true deadlock.
changeDevice()is a synchronous FFI call on the UI isolate, so once native wedges there is no Dart left to time it out. It catches the multi-second stall, which has the same cause; the native test is the one that actually exercises concurrency, since Dart cannot drive two device operations at once.Confirmed passing by @Colton127 on all affected devices, including the emulator and LG V20 that reproduced the freeze.
Note
The web WASM artifact still needs a rebuild to pick up these C++ changes.
Type of Change
Generated by Claude Code