fix: run engine init and teardown off the UI isolate - #533
Conversation
`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
|
Hi @Colton127, thanks again for your PR!! I ran the tests and found that on Android the I think this doesn't depend on this PR, but maybe on #532. Maybe it is related to the changes in the 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 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. |
…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>
|
Great! Thank you very much! Merging, and maybe later today I'll publish on pub.dev |
Summary
Moves blocking native engine initialization and teardown off Flutter’s root/UI isolate.
Fixes #481.
Problem
SoLoud.init()invoked nativeinitEngine()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 throughIsolate.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.
NativeCallablecreation, 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:
When shutdown interrupts initialization, the init future completes with
SoLoudInitializationStoppedByDeinitException.Test adjustment
The existing
AsynchronousDeinitexample test launched initialization withunawaited()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:
deinitAsync();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, anddeinitAsync()provides nonblocking teardown where required.Type of Change