Skip to content

fix: run engine init and teardown off the UI isolate - #533

Merged
alnitak merged 4 commits into
alnitak:mainfrom
Colton127:claude/audit-android-anr-async-hswiev
Aug 8, 2026
Merged

fix: run engine init and teardown off the UI isolate#533
alnitak merged 4 commits into
alnitak:mainfrom
Colton127:claude/audit-android-anr-async-hswiev

Conversation

@Colton127

Copy link
Copy Markdown
Contributor

Summary

Moves blocking native engine initialization and teardown off Flutter’s root/UI isolate.

Fixes #481.

Problem

SoLoud.init() invoked native initEngine() through synchronous Dart FFI.

On Android, engine initialization starts miniaudio’s AAudio device. Miniaudio may synchronously wait for the stream to reach its started state or block while another AAudio operation holds an internal lock.

When init() is called from Flutter’s root isolate, this native work runs on Android’s platform/UI thread. A slow or contended audio HAL can therefore freeze rendering and input long enough to produce an ANR.

Delaying the call, leaving its future unawaited, or wrapping it in a Dart timeout does not move the synchronous native work away from the UI thread.

Solution

The native initEngine() call now runs through Isolate.run() on native platforms.

The worker receives only the native function address and primitive initialization arguments. It invokes the blocking native function and returns its result.

Dart callbacks are not created inside the worker. NativeCallable creation, callback registration, loader initialization, and Dart readiness publication still occur on the original Flutter isolate after native initialization completes.

Web retains a direct implementation behind the asynchronous binding interface.

Lifecycle ordering

Moving init and teardown onto workers allows native execution order to differ from Dart request order. The change therefore includes the lifecycle coordination needed to preserve correct behavior:

  • Initialization requests are serialized through a FIFO future chain.
  • Every initialization captures a lifecycle generation when requested.
  • A later deinit invalidates active and queued initialization requests.
  • Native shutdown is published synchronously before async teardown is dispatched.
  • Native initialization checks for shutdown before starting and before publishing readiness.
  • Native readiness is exposed through a lock-free atomic rather than the replaceable player pointer.
  • Internal initialized state is atomic.
  • Concurrent async teardown calls share one native teardown.
  • Dart callbacks remain alive until native teardown has completed.
  • Stale-engine cleanup after hot restart uses async teardown without cancelling the initialization that requested the cleanup.

When shutdown interrupts initialization, the init future completes with SoLoudInitializationStoppedByDeinitException.

Test adjustment

The existing AsynchronousDeinit example test launched initialization with unawaited() and did not wait for its completion handler.

With initialization now genuinely asynchronous, those handlers could outlive the test and call deinit() during the following test.

The test now:

  • retains the initialization future;
  • awaits deinitAsync();
  • awaits the initialization result;
  • accepts the intentional initialization-cancelled exception;
  • rethrows unexpected errors with their original stack;
  • verifies the native engine is uninitialized before continuing.

This prevents asynchronous lifecycle work from leaking between test iterations or into subsequent tests.

Scope

This change does not remove or shorten miniaudio’s AAudio state-transition wait.

A slow audio HAL may still delay or fail native initialization, but the wait now occurs on a worker rather than freezing Flutter’s UI thread.

This PR does not include broader device lifecycle APIs, device schedulers, interruption recovery, lazy startup, manual start/stop controls, FlutterEngine detach handling, or unrelated audio changes.

Compatibility

The internal initEngine() binding changes from synchronous to asynchronous, but it is not part of the public package API.

Public SoLoud.init() remains asynchronous.

The existing synchronous deinit() method remains available, and deinitAsync() provides nonblocking teardown where required.

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

claude added 2 commits August 6, 2026 01:03
`SoLoud.init()` called native `initEngine()` synchronously from the Dart
root isolate, which on Android is the platform/UI thread. Native init
runs through miniaudio down to `ma_device_start__aaudio`, which blocks in
`AAudioStream_waitForStateChange` until the device reports started. While
that call is on the stack Flutter cannot render frames, process input, or
run timers, so a slow or contended audio HAL is reported as an ANR. No
app-level workaround helps: not awaiting `init()` still runs the FFI call
inline, and a Dart timeout cannot fire while the isolate is blocked.

Move only the blocking native call onto a temporary worker isolate via
`Isolate.run()`, passing the resolved function address and the primitive
init arguments. The worker creates no `NativeCallable`, completer or
stream; callback registration and loader setup still run on the original
Flutter isolate after native init returns, so nothing is ever bound to a
temporary isolate.

Asynchronous init means init and deinit can now interleave, so this also
adds the smallest lifecycle framework that keeps them ordered:

- Init requests run through a FIFO chain, so queued inits cannot resume
  together or reorder.
- A lifecycle generation, incremented by every externally requested
  deinit, invalidates in-flight inits across each await boundary. The
  final check and the readiness publication happen with no yield between
  them, so a cancelled init can never publish success.
- Teardown is asynchronous too (`deinitAsync()`) and deduplicated, so
  concurrent callers share one native dispose. Shutdown is published
  natively through `engine_shutdown_requested` before dispatch, so a late
  init worker cannot resurrect the engine.
- `isInited()` reads a process-global atomic rather than the replaceable
  player object, so a readiness check from the UI isolate neither races
  the `unique_ptr` nor blocks on the lifecycle mutex.
- Stale-engine cleanup after a hot restart tears down asynchronously
  without cancelling the init that requested it.

`deinit()` is unchanged and still supported. Web keeps the same
underlying synchronous WASM behaviour behind the new async interface; the
lifecycle preparation calls are no-ops there.

This does not change miniaudio's AAudio wait, cancel a native call
already in progress, or alter playback behaviour. Starting the device can
still take seconds or fail — it just no longer does so on the UI thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5UaARDHfSpdu3JU6JSQCf
Native `dispose()` now runs on a worker isolate, so its
`player.reset(); player = nullptr; player = make_unique<Player>()`
sequence can execute concurrently with calls made from the main isolate.
The `listPlaybackDevices()` export dereferenced the global `player` with
no null check and no lock, and `SoLoud.listPlaybackDevices()` is
documented as safe to call before initialization so it has no
`isInitialized` gate to fall back on. Calling it while an asynchronous
deinit was in flight could hit a null or destroyed Player.

`Player::listPlaybackDevices()` reads no member state: it builds a local
`ma_context`, enumerates, and uninits it before returning. Make it static
and call it directly, dropping the dependency on the global instance
rather than serialising on the lifecycle mutex — taking that mutex would
block this UI-thread call for the whole of an in-flight `initEngine()`,
reintroducing the stall this branch removes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5UaARDHfSpdu3JU6JSQCf
@alnitak

alnitak commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Hi @Colton127, thanks again for your PR!!

I ran the tests and found that on Android the PlaybackDevices test fails with this:

D/AAudio  (26126): AAudioStream_requestStart(s#10) returned 0 ---------
D/AudioStreamInternalPlay_Client(26126): callbackLoop() entering >>>>>>>>>>>>>>>
V/flutter_soloud NDK(26126): miniaudio_changeDevice_impl: ma_device_start failed with error -1

I think this doesn't depend on this PR, but maybe on #532. Maybe it is related to the changes in the miniaudio_changeDevice_impl function of soloud_miniaudio.cpp.

I just tried using v4.1.6 and it wasn't happening. If you want, or if you do not have the time, I can take a look tomorrow.

@Colton127

Colton127 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Hi @Colton127, thanks again for your PR!!

I ran the tests and found that on Android the PlaybackDevices test fails with this:

D/AAudio  (26126): AAudioStream_requestStart(s#10) returned 0 ---------
D/AudioStreamInternalPlay_Client(26126): callbackLoop() entering >>>>>>>>>>>>>>>
V/flutter_soloud NDK(26126): miniaudio_changeDevice_impl: ma_device_start failed with error -1

I think this doesn't depend on this PR, but maybe on #532. Maybe it is related to the changes in the miniaudio_changeDevice_impl function of soloud_miniaudio.cpp.

I just tried using v4.1.6 and it wasn't happening. If you want, or if you do not have the time, I can take a look tomorrow.

I'm looking into it. I can confirm it surfaced in #532. The test passes on my primary devices - iPhone 11 and S26 Ultra. However, I encountered the exact "ma_device_start failed with error -1" on an Android emulator and LG V20.

The issue occurs at SoLoud.instance.changeDevice and does so regardless of if a device is passed or not.

I'm going to first surface the exception. The test app shouldn't freeze; if it encounters a problem, it should be thrown correctly.

Edit: Resolved in Colton127#27. Merged into this PR. I added regression tests and ensured exceptions are thrown correctly instead of blocking the UI thread. Tests passed on all my devices. WASM still needs to be rebuilt.

Colton127 and others added 2 commits August 7, 2026 19:39
…serialize against other device ops (#27)

* fix: a failed changeDevice() no longer deadlocks the calling thread

`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

* fix: changeDevice() no longer starves the audio thread or races device 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

* docs: trim the changeDevice CHANGELOG entries to the user-visible change

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
@alnitak

alnitak commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Great! Thank you very much!

Merging, and maybe later today I'll publish on pub.dev

@alnitak
alnitak merged commit 5b01266 into alnitak:main Aug 8, 2026
1 check passed
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.

fix: ANR on init

3 participants