Android: never block the platform thread, and tear down on engine detach - #23
Merged
Colton127 merged 1 commit intoJul 26, 2026
Conversation
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>
This was referenced Jul 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two related fixes for apps whose process outlives a
FlutterEngine— routine for a foreground-service audio app such asaudio_service.1. The engine-scoped callback clear no longer blocks the platform thread
clearDartCallbackRegistrationsForEngine()tookinit_deinit_mutexandloadMutex, and runs on the Android platform thread fromonDetachedFromEngine().init_deinit_mutexis held for the whole ofdispose(), 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 onlydart_callback_invocation_mutex, which is never held across a device operation. The per-BufferStream callbacks live insidePlayerand still needinit_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_lockmust not be read as "a dispose is in flight and will do this for me."init_deinit_mutexis also held byloadFile(),loadMem(),initEngine(),changeDevice(), the device start/stop calls andisInited()— 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
Playerpart outsidedart_callback_invocation_mutexalso removes a lock-order inversion againstdisposeSound(), which holdssounds_mutexacrosssoloud.stop()and reachesvoiceEndedCallback(). That was previously safe only because both paths happened to takeloadMutexfirst.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
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.javac -houtput.flutter analyzeandflutter testunchanged.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 usuallySIGKILLs rather than unwinding. I chose that over blocking the platform thread.Ordering
Touches
src/bindings.cppandFlutterSoloudPlugin.java. If #24 (heartbeat) merges first, expect a trivial conflict inbindings.cpp— the two add code in different places neargetAudioDeviceState().Generated by Claude Code