Skip to content

Android: never block the platform thread, and tear down on engine detach - #23

Merged
Colton127 merged 1 commit into
fix/android-hot-restart-callbacksfrom
fix/android-engine-detach-lifecycle
Jul 26, 2026
Merged

Android: never block the platform thread, and tear down on engine detach#23
Colton127 merged 1 commit into
fix/android-hot-restart-callbacksfrom
fix/android-engine-detach-lifecycle

Conversation

@Colton127

Copy link
Copy Markdown
Owner

Summary

Two related fixes for apps whose process outlives a FlutterEngine — routine for a foreground-service audio app such as audio_service.

1. The engine-scoped callback clear no longer blocks the platform thread

clearDartCallbackRegistrationsForEngine() took init_deinit_mutex and loadMutex, and runs on the Android platform thread from onDetachedFromEngine(). init_deinit_mutex is held for the whole of dispose(), which joins the lifecycle scheduler and can inherit a stalled device stop — so detaching while a device operation was in flight could park the UI thread and ANR.

Nulling the three global bridges is what actually stops native code invoking a dead NativeCallable, and needs only dart_callback_invocation_mutex, which is never held across a device operation. The per-BufferStream callbacks live inside Player and still need init_deinit_mutex; that part now tries the lock and, on failure, hands the work to a worker that can afford to wait.

A failed try_lock must not be read as "a dispose is in flight and will do this for me." init_deinit_mutex is also held by loadFile(), loadMem(), initEngine(), changeDevice(), the device start/stop calls and isInited() — none of which clear callbacks. A file load holds it across disk I/O and decode, so a detach landing during one would otherwise silently leave every BufferStream callable pointing at a dying isolate.

Doing the Player part outside dart_callback_invocation_mutex also removes a lock-order inversion against disposeSound(), which holds sounds_mutex across soloud.stop() and reaches voiceEndedCallback(). That was previously safe only because both paths happened to take loadMutex first.

2. Destroying an engine now tears the native engine down

Previously detach only cleared the bridges, leaving an initialized engine, a live output device and a running scheduler with no Dart able to drive them. requestEngineTeardownForEngine() drops the bridges synchronously and hands the blocking teardown to a detached worker.

Both workers capture an initialization generation, bumped by prepareEngineInit(), and abort if a replacement engine initialized while they were waiting — so a late teardown can never dispose a live engine, and a late clear can never erase callbacks a new engine just registered.

Verification

  • A harness modelling a loadFile() holding the mutex across its I/O: the old logic performs 0 clears (callables left registered); the new logic performs the clear once the lock frees, with the caller returning in ~280 µs.
  • A 200-trial race harness confirms a stale teardown never disposes a replacement engine; a 100-trial one confirms the same for the deferred clear. Both clean under ThreadSanitizer.
  • JNI symbol names diffed against javac -h output.

flutter analyze and flutter test unchanged.

Known tradeoff

The teardown worker is detached. If the process exits while it runs, it races static destruction of the global player — the same exposure the existing scheduler thread already has, and Android usually SIGKILLs rather than unwinding. I chose that over blocking the platform thread.

Ordering

Based on #22, not main — the diff shown here is only this change. Merge #21 and #22 first.

Touches src/bindings.cpp and FlutterSoloudPlugin.java. If #24 (heartbeat) merges first, expect a trivial conflict in bindings.cpp — the two add code in different places near getAudioDeviceState().


Generated by Claude Code

Two related fixes for apps whose process outlives a FlutterEngine --
routine for a foreground-service audio app such as audio_service.

1. The engine-scoped callback clear no longer blocks the platform thread.

   clearDartCallbackRegistrationsForEngine() took init_deinit_mutex and
   loadMutex, and runs on the Android platform thread from
   onDetachedFromEngine(). init_deinit_mutex is held for the whole of
   dispose(), which joins the lifecycle scheduler and can inherit a stalled
   device stop, so detaching while a device operation was in flight could
   park the UI thread and ANR.

   Nulling the three global bridges is what actually stops native code
   invoking a dead NativeCallable, and needs only
   dart_callback_invocation_mutex, which is never held across a device
   operation. The per-BufferStream callbacks live inside Player and still
   need init_deinit_mutex; that part now tries the lock and, on failure,
   hands the work to a worker that can afford to wait.

   A failed try_lock must NOT be read as "a dispose is in flight and will do
   this for me": init_deinit_mutex is also held by loadFile(), loadMem(),
   initEngine(), changeDevice(), the device start/stop calls and isInited(),
   none of which clear callbacks. A file load holds it across disk I/O and
   decode, so a detach landing during one would otherwise silently leave
   every BufferStream callable pointing at a dying isolate.

   Doing the Player part outside dart_callback_invocation_mutex also removes
   a lock-order inversion against disposeSound(), which holds sounds_mutex
   across soloud.stop() and reaches voiceEndedCallback().

2. Destroying an engine now tears the native engine down.

   Previously detach only cleared the bridges, leaving an initialized engine,
   a live output device and a running scheduler with no Dart able to drive
   them. requestEngineTeardownForEngine() drops the bridges synchronously and
   hands the blocking teardown to a detached worker.

   Both workers capture an initialization generation, bumped by
   prepareEngineInit(), and abort if a replacement engine initialized while
   they were waiting -- so a late teardown can never dispose a live engine,
   and a late clear can never erase callbacks a new engine just registered.

Verified: a harness modelling a loadFile() holding the mutex across its I/O
shows the old logic performing 0 clears while the new logic performs the
clear once the lock frees, with the caller returning in ~280us. A 200-trial
race harness confirms a stale teardown never disposes a replacement engine,
and a 100-trial one the same for the deferred clear. Both clean under
ThreadSanitizer. JNI symbol names diffed against javac -h output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Colton127
Colton127 merged commit fddc6f4 into fix/android-hot-restart-callbacks Jul 26, 2026
@Colton127
Colton127 deleted the fix/android-engine-detach-lifecycle branch July 26, 2026 01:39
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