Summary
SoLoud.init() performs the native AAudio device start synchronously on the Dart calling thread. When that
caller is the root isolate (the platform/UI thread on Android), a slow or contended audio HAL makes the call block
for >5 s, which trips Android's ANR watchdog. We are seeing this as a recurring production ANR on low/mid Android
devices.
Environment
- flutter_soloud: 4.0.7 (latest)
- Platform: Android (AAudio backend),
in_foreground = true, captured via ApplicationExitInfo
- Devices observed: Infinix X6525D (1.88 GB RAM), TECNO KL4 (Android 14, 3.97 GB RAM) — not limited to the very
weakest hardware
- Call site:
await SoLoud.instance.init(bufferSize: 1024) invoked once at app startup
Impact
Recurring ANRs (multiple unique users) clustered on the first init() after cold start, especially when the audio
HAL is busy or when another AAudio operation (dispose/deinit on an app-lifecycle transition) runs concurrently.
Root cause analysis
FlutterSoLoudFfi.initEngine is declared as a synchronous FFI function
(ffi.NativeFunction<ffi.Int32 Function(ffi.Int, ffi.UnsignedInt, ffi.UnsignedInt, ffi.UnsignedInt)>) and is
invoked synchronously (final ret = _initEngine(...)). The awaits inside SoLoud.init only cover
_initializeNativeCallbacks() / _loader.initialize(), which run after initEngine has already returned — i.e.
after the native device start has already executed. Because Dart FFI calls run on the calling thread, the heavy
native work runs inline on the platform/UI thread.
Native call chain:
SoLoud.init
-> FlutterSoLoudFfi.initEngine (synchronous FFI, runs on the Dart calling thread)
-> Player::init
-> SoLoud::Soloud::init -> miniaudio_init
-> ma_device_start__aaudio
Two blocking variants are observed in the wild:
-
State-transition wait (most frequent / symbolicated):
ma_device_start__aaudio -> ma_wait_for_simple_state_transition__aaudio -> AAudioStream_waitForStateChange -> syscall.
The stream has been requested to start; the calling thread then blocks waiting for the device to reach STARTED.
-
Lock contention with a concurrent AAudio op:
ma_device_start__aaudio -> AAudioStream_requestStart -> aaudio::AudioStream::systemStart -> std::mutex::lock -> NonPI::MutexLockWithTimeout -> __futex_wait_ex_owner.
requestStart blocks on an internal AAudio mutex held by another thread (e.g. a concurrent dispose/deinit).
Key observation: AAudioStream_requestStart() is itself asynchronous (it returns with the stream in
STARTING/STARTED). The blocking in path (1) is miniaudio's synchronous wait for the STARTED transition on the
calling thread, not requestStart itself.
Why app-level workarounds don't solve it
unawaited() / running init() after the first frame only re-times the blocking call; the native work still
runs synchronously on the platform thread.
- Wrapping
init() in .timeout() is useless: the timer microtask can't fire while the isolate is blocked inside the
native syscall.
- Running
init() inside a Dart Isolate.run breaks the engine: native callbacks and completers are bound to the
calling Dart isolate (per the _nativeCallbacksInitialized doc), so a background-isolate init leaves play/stop/
voice-ended callbacks bound to a dead/foreign isolate.
So there is currently no app-level way to move the blocking device start off the platform thread.
Proposed fixes (in order of preference)
-
Run the engine init / device start on a dedicated native (C++) worker thread, and return from the FFI
immediately, signalling completion to Dart via the existing NativeCallable mechanism (the same infra used for
voice-ended callbacks). Offloading to a native thread (not a Dart Isolate) preserves the binding of
NativeCallable callbacks/completers to the calling Dart isolate, avoiding the breakage a Dart-side isolate would
cause. Could be exposed as a new initAsync() or an opt-in flag, e.g. init(..., {bool startDeviceOffThread = true}).
-
Deferred / lazy device start: initialize the engine object and bind callbacks without starting the AAudio
device, then start the device lazily (ideally still off the platform thread) on first play().
-
At minimum: consider whether the AAudio backend can avoid the synchronous post-requestStart
waitForStateChange (relying on the asynchronous STARTING -> STARTED transition) or bound that wait with a short
timeout, so a stalled/contended HAL can't block the caller indefinitely. (Upstream miniaudio:
mackron/miniaudio, ma_device_start__aaudio.) And/or document that init() performs a potentially long,
synchronous native device start on the calling thread.
Reproduction
- Cold-start an app on a low/mid Android device with a busy audio HAL.
- Call
await SoLoud.instance.init(bufferSize: 1024) on the root isolate during startup.
- Trigger app-lifecycle transitions (e.g. lock-screen flicker → inactive/hidden/paused) around the time of
init()
to provoke the AAudio lock-contention path.
- Observe the platform/UI thread blocked >5 s → ANR (
ApplicationExitInfo, in_foreground = true).
References
Summary
SoLoud.init()performs the native AAudio device start synchronously on the Dart calling thread. When thatcaller is the root isolate (the platform/UI thread on Android), a slow or contended audio HAL makes the call block
for >5 s, which trips Android's ANR watchdog. We are seeing this as a recurring production ANR on low/mid Android
devices.
Environment
in_foreground = true, captured viaApplicationExitInfoweakest hardware
await SoLoud.instance.init(bufferSize: 1024)invoked once at app startupImpact
Recurring ANRs (multiple unique users) clustered on the first
init()after cold start, especially when the audioHAL is busy or when another AAudio operation (dispose/deinit on an app-lifecycle transition) runs concurrently.
Root cause analysis
FlutterSoLoudFfi.initEngineis declared as a synchronous FFI function(
ffi.NativeFunction<ffi.Int32 Function(ffi.Int, ffi.UnsignedInt, ffi.UnsignedInt, ffi.UnsignedInt)>) and isinvoked synchronously (
final ret = _initEngine(...)). Theawaits insideSoLoud.initonly cover_initializeNativeCallbacks()/_loader.initialize(), which run afterinitEnginehas already returned — i.e.after the native device start has already executed. Because Dart FFI calls run on the calling thread, the heavy
native work runs inline on the platform/UI thread.
Native call chain:
Two blocking variants are observed in the wild:
State-transition wait (most frequent / symbolicated):
ma_device_start__aaudio -> ma_wait_for_simple_state_transition__aaudio -> AAudioStream_waitForStateChange -> syscall.The stream has been requested to start; the calling thread then blocks waiting for the device to reach
STARTED.Lock contention with a concurrent AAudio op:
ma_device_start__aaudio -> AAudioStream_requestStart -> aaudio::AudioStream::systemStart -> std::mutex::lock -> NonPI::MutexLockWithTimeout -> __futex_wait_ex_owner.requestStartblocks on an internal AAudio mutex held by another thread (e.g. a concurrent dispose/deinit).Key observation:
AAudioStream_requestStart()is itself asynchronous (it returns with the stream inSTARTING/STARTED). The blocking in path (1) is miniaudio's synchronous wait for theSTARTEDtransition on thecalling thread, not
requestStartitself.Why app-level workarounds don't solve it
unawaited()/ runninginit()after the first frame only re-times the blocking call; the native work stillruns synchronously on the platform thread.
init()in.timeout()is useless: the timer microtask can't fire while the isolate is blocked inside thenative syscall.
init()inside a DartIsolate.runbreaks the engine: native callbacks and completers are bound to thecalling Dart isolate (per the
_nativeCallbacksInitializeddoc), so a background-isolate init leaves play/stop/voice-ended callbacks bound to a dead/foreign isolate.
So there is currently no app-level way to move the blocking device start off the platform thread.
Proposed fixes (in order of preference)
Run the engine init / device start on a dedicated native (C++) worker thread, and return from the FFI
immediately, signalling completion to Dart via the existing
NativeCallablemechanism (the same infra used forvoice-ended callbacks). Offloading to a native thread (not a Dart
Isolate) preserves the binding ofNativeCallablecallbacks/completers to the calling Dart isolate, avoiding the breakage a Dart-side isolate wouldcause. Could be exposed as a new
initAsync()or an opt-in flag, e.g.init(..., {bool startDeviceOffThread = true}).Deferred / lazy device start: initialize the engine object and bind callbacks without starting the AAudio
device, then start the device lazily (ideally still off the platform thread) on first
play().At minimum: consider whether the AAudio backend can avoid the synchronous post-
requestStartwaitForStateChange(relying on the asynchronousSTARTING -> STARTEDtransition) or bound that wait with a shorttimeout, so a stalled/contended HAL can't block the caller indefinitely. (Upstream miniaudio:
mackron/miniaudio,ma_device_start__aaudio.) And/or document thatinit()performs a potentially long,synchronous native device start on the calling thread.
Reproduction
await SoLoud.instance.init(bufferSize: 1024)on the root isolate during startup.init()to provoke the AAudio lock-contention path.
ApplicationExitInfo,in_foreground = true).References
ma_device_start__aaudiosynchronous state-transition wait.