Skip to content

fix: changeDevice() ANR on Android — stop starving the audio thread, serialize against other device ops - #27

Merged
Colton127 merged 3 commits into
claude/audit-android-anr-async-hswievfrom
claude/playback-devices-regression-20i0ld
Aug 7, 2026
Merged

fix: changeDevice() ANR on Android — stop starving the audio thread, serialize against other device ops#27
Colton127 merged 3 commits into
claude/audit-android-anr-async-hswievfrom
claude/playback-devices-regression-20i0ld

Conversation

@Colton127

Copy link
Copy Markdown
Owner

Description

SoLoud.instance.changeDevice() permanently froze the app (ANR) on an Android emulator and an LG V20, with or without a newDevice argument. An S26 Ultra and an iPhone 11 were unaffected. The last thing in the log before the freeze:

V/flutter_soloud NDK: miniaudio_changeDevice_impl: ma_device_start failed with error -1
D/AAudio  : AAudioStream_close(s#51) called ---------------
D/AudioTrack: stop(185): called with 0 frames delivered

Root cause

Both the -1 and 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_audiomixerSoloud::mixmix_internal — takes that same mutex.

Why the start failed. On AAudio's legacy (non-MMAP) path, AAudioStream_requestStart returns OK but leaves the stream in STARTING; it only reaches STARTED once the first data callback has run. That callback was blocked on the mutex the calling thread held, so ma_wait_for_simple_state_transition__aaudio() (5-second timeout, miniaudio.h) gave up and ma_device_start() returned MA_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 reports STARTED without 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 in Player::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() and soloud_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 inside soloud_miniaudio_resume().

This is not lifecycle-only. Player::pauseEngineScheduler() runs on its own thread and calls Soloud::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 through pDevice->pUserData and takes the mutex itself inside Soloud::mix(), and ma_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.
  • The lock's declaring comment claimed it only covered resume-vs-deinit; it now names all four operations it guards and warns that it is not SoLoud's audio-thread mutex.

Lock ordering was checked: gDeviceOpsMutex → miniaudio's internal rerouteLock in every path, no inversion. Nothing re-enters through on_notification — the state-changed callback posts to a Dart port rather than calling back synchronously.

A device change that still fails now returns audioDeviceFailedToStartSoLoudAudioDeviceFailedToStartCppException rather 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 requires deinit()/init().

Two smaller things on that same path: the ma_device_init failure branch now logs like its ma_device_start sibling, and the failure path resets gDeviceStopped, which was left false for a device that never ran — a following deinit() 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 in test/). 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 a FAILED line. Unlike the other native tests it needs the miniaudio backend and a real output device, so it prints SKIPPED and exits 0 where there is none.

example/tests/tests/playback_devices.dart now times every changeDevice() 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

  • ✨ New feature (non-breaking change which adds functionality)
  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • ❌ Breaking change (fix or feature that would cause existing functionality to change)
  • 🧹 Code refactor
  • ✅ Build configuration change
  • 📝 Documentation
  • 🗑️ Chore

Generated by Claude Code

claude added 3 commits August 7, 2026 22:26
`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
@Colton127
Colton127 merged commit 77e0148 into claude/audit-android-anr-async-hswiev Aug 7, 2026
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