Skip to content

fix: ANR on init #481

Description

@tanchuev

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:

  1. 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.

  2. 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)

  1. 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}).

  2. 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().

  3. 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

  1. Cold-start an app on a low/mid Android device with a busy audio HAL.
  2. Call await SoLoud.instance.init(bufferSize: 1024) on the root isolate during startup.
  3. Trigger app-lifecycle transitions (e.g. lock-screen flicker → inactive/hidden/paused) around the time of init()
    to provoke the AAudio lock-contention path.
  4. Observe the platform/UI thread blocked >5 s → ANR (ApplicationExitInfo, in_foreground = true).

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions