Skip to content

Handle engine teardown for add-to-app and hot restart scenarios - #25

Merged
Colton127 merged 10 commits into
mainfrom
claude/soloud-unknown-error-lvb-0aldcf
Jul 26, 2026
Merged

Handle engine teardown for add-to-app and hot restart scenarios#25
Colton127 merged 10 commits into
mainfrom
claude/soloud-unknown-error-lvb-0aldcf

Conversation

@Colton127

Copy link
Copy Markdown
Owner

Description

This PR fixes a critical issue where the native audio engine would remain initialized and keep an output device running after its owning FlutterEngine is destroyed in add-to-app scenarios or during hot restart. This could lead to undefined behavior when native code attempts to invoke callbacks into a dead isolate.

Key Changes

  1. Engine Lifecycle Management: Added requestEngineTeardownForEngine() to properly tear down the native engine when a FlutterEngine is destroyed while the process continues running (add-to-app, foreground services, etc.).

  2. Generation-Based Synchronization: Introduced engineInitGeneration atomic counter to prevent race conditions where a queued teardown from a detached engine could incorrectly dispose a replacement engine that initialized while the worker was waiting for locks.

  3. Non-Blocking Teardown: The blocking teardown operations (device stop, scheduler join) are delegated to detached worker threads to avoid ANR (Application Not Responding) on the Android platform thread.

  4. Hot Restart Support: Added onPreEngineRestart() lifecycle listener to clear Dart callback bridges during hot restart, allowing the new isolate to properly reinitialize.

  5. Refactored Callback Clearing: Split callback clearing into:

    • clearDartCallbackPointersLocked(): Makes global Dart bridges inert (fast, no blocking)
    • clearPlayerDartCallbackRegistrationsLocked(): Clears per-BufferStream callbacks
    • queuePlayerDartCallbackClear(): Non-blocking variant for platform thread
  6. Android Plugin Updates: Enhanced FlutterSoloudPlugin to:

    • Register an EngineLifecycleListener on attachment
    • Request teardown on both onEngineWillDestroy() and onDetachedFromEngine()
    • Implement idempotent teardown to handle multiple detach paths

Technical Details

  • Uses try-lock pattern in clearDartCallbackRegistrationsForEngine() to avoid blocking the platform thread
  • Worker threads capture the initialization generation to abort if a replacement engine initializes
  • Maintains lock ordering consistency (dart_callback_invocation_mutex is never held across device operations)
  • All changes are backward compatible; existing dispose() behavior is preserved

Type of Change

  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 🧹 Code refactor

Testing

The fix addresses a race condition that manifests in add-to-app and hot restart scenarios. Existing unit tests continue to pass. Manual testing should verify:

  • Hot restart no longer leaves stale callback registrations
  • Add-to-app engine detach properly tears down the native engine
  • No ANR occurs during engine teardown on the platform thread

https://claude.ai/code/session_01PWTMdp5kHauqc4Jjwp7b5t

claude and others added 10 commits July 25, 2026 04:29
onDetachedFromEngine does not fire on hot restart. The FlutterEngine and its
id are unchanged while the Dart isolate is replaced, so every registered
NativeCallable silently goes stale with no notification -- and invoking a
callable whose isolate is gone is undefined behaviour.

The plugin now registers a FlutterEngine.EngineLifecycleListener and clears
the registrations in onPreEngineRestart(). onEngineWillDestroy() clears too;
it fires just before the plugin registry is destroyed, while the engine is
still valid, so it is a slightly earlier point than the detach hook. The
listener is removed on detach.

Only the callback bridges are cleared here, not the whole engine: after a hot
restart the new isolate's init() finds the native engine still initialized
and deinits it itself.

Compiled under -Xlint:all against stubs mirroring the real embedding API, and
the JNI symbol name diffed against javac -h output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
…ycle

Android: never block the platform thread, and tear down on engine detach
Brings in the Android engine-lifecycle work from PRs #22 and #23, which
were never merged to main: hot restart now clears stale Dart callback
registrations, the engine-scoped callback clear no longer blocks the
platform thread, and destroying a FlutterEngine tears the native engine
down instead of leaving a live device with no Dart to drive it.

Conflict resolution (both in files main improved after the branch forked):

- FlutterSoloudPlugin.java: keep main's retryable ensureNativeLibraryLoaded()
  and its UnsatisfiedLinkError try/catch rather than the branch's older
  one-shot loader, and extend the same guard to the new
  nativeRequestEngineTeardownForEngine() call.
- CHANGELOG.md: both sides appended to 4.0.13; all entries kept.

Also tightens requestEngineTeardownForEngine() to require strict callback
ownership. Accepting an unowned registration was unsafe: it is also the
state of a replacement engine that has initialized but not yet registered
its callbacks, and engineInitGeneration cannot detect that case because the
replacement's prepareEngineInit() runs before the detaching engine captures
the generation. A detach could therefore dispose a live engine.

Verified: FlutterSoloudPlugin.java compiles clean under -Xlint:all against
embedding stubs, both JNI symbol names match javac -h output, bindings.cpp
passes g++ -fsyntax-only, flutter analyze reports only the 6 pre-existing
info lints in example/tests, and flutter test passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWTMdp5kHauqc4Jjwp7b5t
Callback ownership is the wrong signal for deciding whether a detaching
FlutterEngine may dispose the native engine, and it fails in both
directions.

dartCallbackOwnerEngineId is unset for the whole of an initialization:
Dart registers callbacks only after initEngine() returns, and initEngine()
opens the audio device, which takes seconds on Android. An engine destroyed
in that window could not tear down the Player, scheduler and device it had
just built -- exactly the state this teardown path exists to prevent.

It is also still set to the *previous* engine once a replacement has called
prepareEngineInit(). A detaching engine was therefore accepted, captured the
replacement's already-bumped generation, and went on to dispose a live
engine -- or raised engine_shutdown_requested after the replacement lowered
it, so the replacement's initEngine() refused to initialize. The generation
counter cannot separate these: it is bumped when an initialization starts,
which is before the teardown reads it.

prepareEngineInit() now takes the owning engine id and claims the native
engine under engine_lifecycle_mutex, alongside the generation and the
shutdown flag. A teardown is accepted only while that claim still names the
detaching engine, and its worker re-checks the whole claim before disposing.
Verifying the claim and raising the shutdown flag in one critical section is
what stops a detach cancelling a replacement's initialization.
dartCallbackOwnerEngineId now decides only whose callable pointers may be
cleared, which is all it was ever able to answer.

Also fixes the Java teardown flag, which was set before the library load and
the JNI call, making a failure of either terminal: onDetachedFromEngine()
found teardownRequested already set and returned, so the retry the lazy
loader is documented to allow could never happen.

Verified with a harness that #includes the claim helpers verbatim from
bindings.cpp so it cannot drift, over five scenarios plus a 400-trial race:

  gate                          gap  overlap  ordinary  deinit  race
  callback-owner-or-unowned     ok   BROKEN   ok        BROKEN  95/400
  strict callback owner         BROKEN BROKEN ok        ok      400/400
  lifecycle ownership           ok   ok       ok        ok      400/400

Race column under ThreadSanitizer, which surfaces the interleaving far more
often than default timing (1/400). No TSan race reports. bindings.cpp passes
g++ -Wall -Wextra, the plugin compiles clean under -Xlint:all against
embedding stubs, flutter analyze reports only the 6 pre-existing info lints,
and flutter test passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWTMdp5kHauqc4Jjwp7b5t
Since the teardown hook replaced the plain callback clear on the Java detach
path, requestEngineTeardownForEngine() is the only thing that retires Dart
callables when a FlutterEngine is destroyed. Gating that clear on the
lifecycle claim therefore left a hole: an engine can own the callables
without owning the claim.

initEngine() carries no ownership token, so the order in which two engines
call prepareEngineInit() does not determine the order their initialization
workers acquire init_deinit_mutex. Engine A can claim, engine B can claim
after it, and A's worker can still be the one that initializes the Player.
A then registers its callables -- registration only happens after
initEngine() returns -- leaving callback owner A with lifecycle owner B.
Destroying A was correctly refused the engine teardown, but returned before
clearing anything, so native code kept invoking callables belonging to a
dead isolate. That is the undefined behaviour this path exists to prevent.

The clear now runs before the lifecycle gate and stays scoped by callback
ownership, which is the question it was always able to answer. It cannot
touch another engine's callables: clearDartCallbackRegistrationsForEngine()
returns early unless the caller owns the registration.

Harness scenario 6 drives that interleaving directly and asserts the
callables are retired on a refused teardown: it fails with the clear after
the gate and passes with it before. Scenarios 1-5 and the 400-trial race are
unchanged, clean under ThreadSanitizer.

This does not close the underlying ownership gap -- initEngine(),
setDartEventCallback(), requestEngineShutdown() and dispose() still take no
ownership token, so a stale initialization can still run under a newer
engine's claim, and a stale deinit can still dispose a live engine. Fixing
that needs the initialization lease threaded through those calls, which
changes exported signatures the prebuilt web wasm depends on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWTMdp5kHauqc4Jjwp7b5t
prepareEngineInit() claimed the native engine, but nothing downstream carried
that claim. initEngine() took only the audio configuration and checked a
process-global shutdown flag, so calling prepareEngineInit() first did not
determine which initialization worker reached init_deinit_mutex first. Three
consequences, all reachable with two FlutterEngines:

- A superseded initialization could build over the engine that replaced it.
  It then registered its callables, leaving callback owner A with lifecycle
  owner B -- and A's later teardown was refused, so its callables outlived
  its isolate.
- setDartEventCallback() validated nothing, so an engine returning from Dart
  after being superseded overwrote the live engine's callables, silently, with
  the replacement still believing its registration was current.
- requestEngineShutdown() and dispose() were unscoped, so a queued deinit
  worker could dispose a live engine. disposeLocked() then released whatever
  claim was current, leaving the replacement initialized but unowned -- and an
  unowned engine can never be torn down when its FlutterEngine is destroyed.

prepareEngineInit() now returns a lease. initEngineOwned(),
setDartEventCallback(), requestEngineShutdown() and disposeForEngine() take
(engineId, lease) and refuse to act once it is no longer current.
initEngineOwned() revalidates after Player::init() returns, since the device
open blocks for seconds and ownership can change underneath it; a stale
initialization disposes the Player it just built before releasing the mutex,
rather than leaving an orphaned device and scheduler running. disposeLocked()
takes the claim it is entitled to retire and leaves a claim that has moved on
alone.

initEngine() and dispose() keep their signatures and become ownership-unaware
wrappers. The web build binds those two exported symbols directly from the
committed prebuilt wasm, which cannot be regenerated here, and web has no
FlutterEngine lifecycle for a lease to describe.

Dart holds the claim in the FFI binding rather than threading it through
SoLoud: the binding is already one-per-isolate and an isolate belongs to
exactly one FlutterEngine, so those fields are that engine's claim.

Adds four test-only native barriers (before the init lock, after init
succeeded, before callback registration, before the dispose lock) following
the existing debugTriggerAudioInterruption precedent. The interleavings that
matter are inside the initialization worker while it holds init_deinit_mutex,
which no Dart-side sequencing can reach.

Harness now covers 10 scenarios including a stale init refused by its lease,
a claim lost during the device open (orphan disposed), a stale deinit refused
with the live claim intact, and a superseded engine's late registration
refused. All pass, clean under ThreadSanitizer. Scenario 6 was rewritten: the
lease makes its original premise unreachable, so it now builds the
callback/lifecycle owner split the way that is still reachable.

flutter analyze unchanged at the 6 pre-existing info lints; flutter test
passes; bindings.cpp clean under -Wall -Wextra; plugin clean under -Xlint:all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWTMdp5kHauqc4Jjwp7b5t
Two lifecycle entry points validated the lease through a helper that took and
released engine_lifecycle_mutex, then acted after releasing it. That is the
same check-then-act window already closed in tryBeginEngineTeardown(), left
open in the two places that were converted to the lease later.

requestEngineShutdown(): a replacement engine's prepareEngineInit() could
claim and lower the shutdown flag between the lease check and the store,
after which the store raised it again and the replacement's initEngine()
returned backendNotInited. The replacement-cancellation race, relocated into
the ordinary Dart shutdown path.

setDartEventCallback(): a replacement could claim between the lease check and
the pointer publication, so the superseded engine's callables overwrote the
live engine's anyway -- exactly what validating the lease was meant to stop.

Both now validate and act inside one engine_lifecycle_mutex critical section.
That means engine_lifecycle_mutex is held while acquiring
dart_callback_invocation_mutex, so it is no longer a leaf lock; nothing takes
those two in the opposite order, and the ordering rule is now stated where the
mutex is declared.

setDartEventCallback() also returned void, so a refusal was invisible to Dart.
The isolate created three NativeCallables, called a void function, and carried
on to initialize the loader and publish _nativeCallbacksInitialized = true --
believing it was fully initialized with no callbacks registered against a
native engine belonging to another FlutterEngine. It now returns whether the
registration was accepted; on refusal Dart closes the callables it just
created and throws SoLoudInitializationSupersededException, which the existing
handler in init() turns into a failed initialization. The teardown that
handler attempts is scoped to the same stale lease and is correctly refused,
so it cannot dispose the replacement's engine either.

Harness scenarios 11 and 12 drive both windows from a second thread, since
the atomic form shuts the replacement out by holding the mutex and an inline
interleave would only self-deadlock. Built both ways: the non-atomic form
fails both (B's initialization cancelled; callables and lifecycle owner
disagree), the shipping form passes. Scenario 12 asserts the invariant that
holds whichever engine wins the race -- the live callables belong to the
lifecycle owner -- rather than a fixed winner. All 12 pass, clean under
ThreadSanitizer.

flutter analyze unchanged at the 6 pre-existing info lints; flutter test
passes; bindings.cpp clean under -Wall -Wextra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWTMdp5kHauqc4Jjwp7b5t
Reverts 69cdd85 and 69e2dcf. Both are correct, but every scenario they
address requires two concurrent FlutterEngines, and this plugin's consumer
runs exactly one: AudioServiceActivity provides a process-global cached
engine via AudioServicePlugin.getFlutterEngine() with
shouldDestroyEngineWithHost() = false, so the engine deliberately outlives
the Activity and is never replaced by a second one.

What that leaves in place is what a single-engine foreground-service app
actually needs: tearing the native engine down when its FlutterEngine is
destroyed rather than leaving a live output device and lifecycle scheduler
with no Dart to drive them, and gating that on the lifecycle claim rather
than callback ownership -- which matters single-engine too, since the window
where initEngine() has opened the audio device but Dart has not yet
registered callbacks is seconds on Android/AAudio.

Preserved on claude/engine-lifecycle-lease, including the lease plumbing, the
test-only lifecycle barriers, SoLoudInitializationSupersededException and the
12-scenario harness. Restore it if a second engine is ever introduced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWTMdp5kHauqc4Jjwp7b5t
Fixes RELIEFMIX-3MP, which is two distinct failures reaching the same
symptom: the app believes it is playing and produces silence.

Android -- SoLoudUnknownErrorException from startAudioDevice(). Every
miniaudio failure is flattened to UNKNOWN_ERROR by soloud_miniaudio_resume(),
so "an unknown error on the C++ side" means precisely that ma_device_start()
failed and the reason was discarded. There was no retry and no recovery
anywhere on that path, so one failure left the device stopped indefinitely.

The stream is kept open across an idle stop, so a device stopped for a while
can be holding a stream the OS has since invalidated. AAudio only reports a
disconnect through the error callback of a *running* stream, so a stream torn
down while stopped -- a route change, or the framework reclaiming resources
from a backgrounded app -- is never rerouted by miniaudio, and the staleness
surfaces only here. That matches the field reports: both Android users are on
Android 16, and one had been backgrounded 16 hours with repeated low-memory
warnings before pressing play. It also explains the first-seen date: 4.0.13
is the release that started stopping the Android device when idle, so before
it a stale restart was not reachable.

performAudioDeviceStart() now rebuilds the device and retries once. Callers
hold mDeviceLifecycleOperationMutex so this cannot interleave with another
device operation, and voices, sources and filters live in SoLoud rather than
in the device, so playback resumes where it left off. One retry only: a
freshly built device that will not start is not failing from staleness.

iOS -- the same issue collected events where startAudioDevice() returned
success and the device stayed stopped. That is Player::startAudioDevice()
returning noError under mInterruptionActive without touching the device. The
flag is set from miniaudio's AVAudioSession observer and cleared only by
init()/deinit(), and iOS does not reliably deliver
AVAudioSessionInterruptionTypeEnded -- notably when the interruption ends
while the app is backgrounded. One missed notification latched it true and
made every later start a silent no-op for the life of the engine. The event
timing agrees: five failures within twenty seconds, identical each time,
which is a latched state rather than a transient race.

An explicit start is an authoritative request from the app, so it now clears
the latch and performs the start. If an interruption really is still in force
the OS refuses to activate the session and the start fails, surfacing a real
error instead of a false success; a genuine new interruption sets the flag
again through the normal callback.

Adds a regression test for the iOS half: interrupt, drop the matching "ended"
notification, and assert an explicit start still reaches started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWTMdp5kHauqc4Jjwp7b5t
@Colton127
Colton127 merged commit e4b50bd into main Jul 26, 2026
1 check passed
@Colton127
Colton127 deleted the claude/soloud-unknown-error-lvb-0aldcf branch July 26, 2026 20:58
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