diff --git a/CHANGELOG.md b/CHANGELOG.md index d705466a..a9324764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ #### 4.0.13 (20 Jul 2026) +- fix: a failed audio device start now rebuilds the device and retries once instead of returning an unrecoverable error. The backend keeps the output stream open across an idle stop, so a device stopped for a while can hold a stream the OS has since invalidated; on Android AAudio only reports a disconnect through the error callback of a *running* stream, so a stream torn down while stopped is never rerouted and the staleness surfaces as `startAudioDevice()` failing with an unknown error. Voices and loaded sources live in SoLoud rather than the device, so playback resumes where it left off +- fix: `startAudioDevice()` no longer reports success while leaving the device stopped when an OS interruption is still flagged as active. iOS does not reliably deliver `AVAudioSessionInterruptionTypeEnded` -- notably when the interruption ends while the app is backgrounded -- and the flag was otherwise cleared only by `init()`/`deinit()`, so one missed notification made every later start a silent no-op for the life of the engine. An explicit start is now authoritative: it clears the flag and performs the start, surfacing a real error if the OS still refuses - fix: the voice-ended callback is no longer invoked while SoLoud's audio mutex is held. It ran there via `stopVoice_internal()` and reached back into `Player` state, which deadlocked against `disposeSound()` (which holds `sounds_mutex` across `soloud.stop()`). The symptom was a wedged engine: handles and sources still looked valid, no audio was produced, and `deinit()` never completed. Ended voices are now queued and dispatched once the mutex is released +- Android: destroying a `FlutterEngine` while the process keeps running (foreground-service apps such as `audio_service`) now tears the native engine down instead of leaving an initialized engine and a running output device with no Dart left to drive them. The blocking part runs on a native worker thread. A `FlutterEngine` claims the native engine for the whole of its initialization, so an engine destroyed mid-init still tears down what it built, and a detaching engine can neither dispose nor cancel the initialization of a replacement that has already claimed it +- fix: detaching a `FlutterEngine` on Android no longer blocks the platform thread on the engine-teardown mutex, which could ANR if a device operation was in flight +- fix: Android hot restart now clears stale Dart callback registrations. Hot restart replaces the isolate without detaching plugins, so the registered `NativeCallable`s silently went stale - Android: the plugin now does no native work at app startup. Plugin registration and `onAttachedToEngine` are pure Java bookkeeping; the native library is loaded lazily, only when an engine-lifecycle hook actually has to call into it. Previously a static initializer pulled the whole library onto the main thread during app launch even for apps that never played a sound, and a load failure there crashed plugin registration - fix: Waveform audio sources do not match engine sample rate #501. Thanks to @Colton127 - Android now stops the audio device when idle (no active voices) like every other platform, releasing the audioserver `AudioMix` partial wakelock #250; use `setAudioDeviceIdleTimeout()` to keep it running diff --git a/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java b/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java index 5f0d30fd..fb3ebf4b 100644 --- a/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java +++ b/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java @@ -2,8 +2,25 @@ import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.flutter.embedding.engine.FlutterEngine; import io.flutter.embedding.engine.plugins.FlutterPlugin; +/** + * Keeps flutter_soloud's process-global native state in step with the lifetime + * of the FlutterEngine that owns it. + * + *

The native engine (the SoLoud player, its output device, its lifecycle + * scheduler and the registered Dart callback pointers) lives for the whole + * process, while the Dart isolate that drives it belongs to a single + * FlutterEngine. When an engine goes away but the process keeps running -- + * routine for a foreground-service audio app, e.g. audio_service -- native code + * would otherwise keep calling into NativeCallables whose isolate is gone + * (undefined behaviour) and keep an output device running with nothing left to + * control it. Only the embedder can observe that transition: Dart's + * {@code detached} lifecycle state is not guaranteed to arrive first, and there + * is no reliable root-isolate exit hook. + */ public final class FlutterSoloudPlugin implements FlutterPlugin { private static final String TAG = "FlutterSoloudPlugin"; @@ -27,7 +44,18 @@ public final class FlutterSoloudPlugin implements FlutterPlugin { private static native boolean nativeClearDartCallbackRegistrationsForEngine(long engineId); - private Long engineId; + private static native boolean + nativeRequestEngineTeardownForEngine(long engineId); + + @Nullable private FlutterEngine flutterEngine; + @Nullable private Long engineId; + @Nullable private FlutterEngine.EngineLifecycleListener lifecycleListener; + + /** + * onEngineWillDestroy() and onDetachedFromEngine() both fire on a real + * engine destroy; the teardown must only be requested once. + */ + private boolean teardownRequested = false; private static synchronized boolean ensureNativeLibraryLoaded() { if (nativeLibraryLoaded) { @@ -53,27 +81,103 @@ public void onAttachedToEngine( ) { // Deliberately does no native work. This runs during app launch for // every app that depends on the plugin, whether or not it ever uses - // SoLoud, so it must stay pure Java bookkeeping. - engineId = binding.getFlutterEngine().getEngineId(); + // SoLoud, so it must stay pure Java bookkeeping: read the engine id and + // register a listener. Nothing here loads the native library, opens a + // device, or starts a thread. + final FlutterEngine engine = binding.getFlutterEngine(); + flutterEngine = engine; + engineId = engine.getEngineId(); + teardownRequested = false; + + lifecycleListener = new FlutterEngine.EngineLifecycleListener() { + @Override + public void onPreEngineRestart() { + // Hot restart replaces the Dart isolate but does not detach + // plugins, and the engine id is unchanged -- so without this the + // registered NativeCallables silently go stale. Only the bridges + // are cleared: the new isolate's init() finds the native engine + // still initialized and deinits it itself. + clearDartCallbackRegistrations(); + } + + @Override + public void onEngineWillDestroy() { + // Fires just before the plugin registry is destroyed. The engine + // is still valid here, so this is the earliest safe point. + requestEngineTeardown(); + } + }; + engine.addEngineLifecycleListener(lifecycleListener); } @Override public void onDetachedFromEngine( @NonNull FlutterPluginBinding binding ) { - final Long detachedEngineId = engineId; + final FlutterEngine engine = flutterEngine; + final FlutterEngine.EngineLifecycleListener listener = lifecycleListener; + + if (engine != null && listener != null) { + engine.removeEngineLifecycleListener(listener); + } + + // Requested here too: onEngineWillDestroy() is not reached on every + // detach path, and requestEngineTeardown() is idempotent. + requestEngineTeardown(); + + flutterEngine = null; engineId = null; + lifecycleListener = null; + } + + private void clearDartCallbackRegistrations() { + final Long id = engineId; + if (id == null || !ensureNativeLibraryLoaded()) { + return; + } + + try { + nativeClearDartCallbackRegistrationsForEngine(id); + } catch (UnsatisfiedLinkError error) { + Log.w( + TAG, + "Unable to clear Dart callback registrations", + error + ); + } + } + + /** + * Drops the Dart bridges and asks native code to tear the engine down. The + * blocking part of the teardown (stopping the device, joining the lifecycle + * scheduler) runs on a native worker thread, so this returns promptly and + * never blocks the platform thread. + */ + private void requestEngineTeardown() { + final Long id = engineId; + if (id == null || teardownRequested) { + return; + } - if (detachedEngineId == null || !ensureNativeLibraryLoaded()) { + // Marked as requested only once native code has accepted it. Setting it + // up front would make a failed library load, a failed JNI call, or a + // native worker that could not be spawned terminal: onDetachedFromEngine() + // is the retry for a hook that ran too early or hit a load that can still + // succeed later, and it would have found the flag already set. + // + // A native `false` is not always retryable -- it also means another + // engine owns the native engine, or nothing is claimed -- but retrying + // those costs one rejected call and keeps the recoverable cases working. + if (!ensureNativeLibraryLoaded()) { return; } try { - nativeClearDartCallbackRegistrationsForEngine(detachedEngineId); + teardownRequested = nativeRequestEngineTeardownForEngine(id); } catch (UnsatisfiedLinkError error) { Log.w( TAG, - "Unable to clear Dart callback registrations during engine teardown", + "Unable to request native engine teardown", error ); } diff --git a/example/tests/tests/audio_device_lifecycle_races.dart b/example/tests/tests/audio_device_lifecycle_races.dart index ef3a804d..9d822d0c 100644 --- a/example/tests/tests/audio_device_lifecycle_races.dart +++ b/example/tests/tests/audio_device_lifecycle_races.dart @@ -333,6 +333,34 @@ Future testAudioDeviceLifecycleRaces() async { ); output.writeln('Idle/keep-alive interruption policies: OK'); + // An interruption whose "ended" notification never arrives must not wedge + // the device permanently. iOS does not reliably deliver + // AVAudioSessionInterruptionTypeEnded -- notably when the interruption ends + // while the app is backgrounded -- and the interruption flag is otherwise + // only cleared by init()/deinit(). While it was latched, startAudioDevice() + // returned success without touching the device, so the app saw a healthy + // API and silence. An explicit start is authoritative and must recover. + SoLoud.instance.setAudioDeviceIdleTimeout(null); + state = await _waitForDeviceState(AudioDeviceState.started); + assert( + state == AudioDeviceState.started, + 'Keep-alive did not start device before the missed-end check.', + ); + SoLoudController().soLoudFFI.debugTriggerAudioInterruption(began: true); + state = await _waitForDeviceState(AudioDeviceState.stopped); + assert( + state == AudioDeviceState.stopped, + 'Interruption did not stop device before the missed-end check.', + ); + // Deliberately no matching `began: false`: that is the dropped one. + await SoLoud.instance.startAudioDevice(); + state = await _waitForDeviceState(AudioDeviceState.started); + assert( + state == AudioDeviceState.started, + 'Explicit start did not recover from a missed interruption end.', + ); + output.writeln('Explicit start recovers a missed interruption end: OK'); + await SoLoud.instance.stop(handle); await SoLoud.instance.disposeSource(waveform); diff --git a/lib/src/bindings/bindings_player_ffi.dart b/lib/src/bindings/bindings_player_ffi.dart index 20117046..9cbf7660 100644 --- a/lib/src/bindings/bindings_player_ffi.dart +++ b/lib/src/bindings/bindings_player_ffi.dart @@ -395,12 +395,21 @@ class FlutterSoLoudFfi extends FlutterSoLoud { >('initEngine'); @override - void prepareEngineInit() => _prepareEngineInit(); + void prepareEngineInit() { + // Claims the native engine for this FlutterEngine for the whole of the + // initialization that follows, so an engine destroyed mid-init can still + // tear down what it built. Read the same way as in + // [setDartEventCallbacks]; -1 on platforms that expose no engine id, which + // are also the platforms with no engine-lifecycle hooks to act on it. + _prepareEngineInit(ui.PlatformDispatcher.instance.engineId ?? -1); + } late final _prepareEngineInitPtr = - _lookup>('prepareEngineInit'); + _lookup>( + 'prepareEngineInit', + ); late final _prepareEngineInit = _prepareEngineInitPtr - .asFunction(); + .asFunction(); @override void requestEngineShutdown() => _requestEngineShutdown(); diff --git a/src/bindings.cpp b/src/bindings.cpp index 16cc4e91..b9ffadd7 100644 --- a/src/bindings.cpp +++ b/src/bindings.cpp @@ -26,6 +26,7 @@ #include #include #include +#include std::mutex dart_callback_invocation_mutex; @@ -33,11 +34,75 @@ std::mutex dart_callback_invocation_mutex; // never be held while joining the lifecycle scheduler, starting or stopping // the audio device, disposing Player, or destroying native audio sources. +// Defined below with the other C-linkage globals. Declared here so the engine +// lifecycle helpers can raise it while holding engine_lifecycle_mutex. +extern "C" std::atomic engine_shutdown_requested; + namespace { -constexpr int64_t kNoDartCallbackOwnerEngineId = -1; +constexpr int64_t kNoEngineId = -1; + +// Protected by dart_callback_invocation_mutex. Decides only whose *callable +// pointers* may be cleared -- never whether the engine may be torn down. It is +// unset for the whole of an initialization, because Dart registers callbacks +// only after initEngine() has returned. +int64_t dartCallbackOwnerEngineId = kNoEngineId; + +// Which FlutterEngine currently claims the native engine, and a counter +// advanced by every prepareEngineInit(). Claimed at the *start* of an +// initialization rather than when callbacks register, so the engine has a +// lifecycle owner during the whole init -- including the long window where +// initEngine() has opened the device but Dart has not registered callbacks yet. +// +// Both fields are read and written together, so they are guarded by a mutex +// rather than made individually atomic: a teardown must observe a consistent +// (owner, generation) pair, and no interleaving of two atomic loads gives that. +// This is a leaf lock -- never held while acquiring another lock or performing +// any blocking work. +std::mutex engine_lifecycle_mutex; +int64_t nativeInitOwnerEngineId = kNoEngineId; +uint64_t engineInitGeneration = 0; + +struct EngineLifecycleClaim { + int64_t ownerEngineId; + uint64_t generation; +}; + +EngineLifecycleClaim currentEngineLifecycleClaim() { + std::lock_guard guard(engine_lifecycle_mutex); + return EngineLifecycleClaim{nativeInitOwnerEngineId, engineInitGeneration}; +} + +/// Accept a teardown for [engine_id] and capture the claim it is tearing down. +/// +/// Verifying the claim and raising engine_shutdown_requested in one critical +/// section is what stops a detaching engine cancelling a *replacement* engine's +/// initialization. prepareEngineInit() lowers that flag and re-claims under the +/// same mutex, so the two can no longer interleave as: teardown reads the old +/// claim, replacement re-claims and lowers the flag, teardown raises it again, +/// and the replacement's initEngine() then refuses to initialize. +bool tryBeginEngineTeardown(int64_t engine_id, EngineLifecycleClaim *out) { + std::lock_guard guard(engine_lifecycle_mutex); + + if (nativeInitOwnerEngineId != engine_id) + return false; + + *out = EngineLifecycleClaim{nativeInitOwnerEngineId, engineInitGeneration}; + // Rejects an initialization worker of this same engine that has not entered + // native code yet. + engine_shutdown_requested.store(true, std::memory_order_release); + return true; +} -// Protected by dart_callback_invocation_mutex. -int64_t dartCallbackOwnerEngineId = kNoDartCallbackOwnerEngineId; +bool engineLifecycleClaimIsCurrent(const EngineLifecycleClaim &claim) { + std::lock_guard guard(engine_lifecycle_mutex); + return nativeInitOwnerEngineId == claim.ownerEngineId && + engineInitGeneration == claim.generation; +} + +void releaseEngineLifecycleClaim() { + std::lock_guard guard(engine_lifecycle_mutex); + nativeInitOwnerEngineId = kNoEngineId; +} } #ifdef __cplusplus @@ -219,17 +284,61 @@ setDartEventCallback(dartVoiceEndedCallback_t voice_ended_callback, dartCallbackOwnerEngineId = owner_engine_id; } -static void clearDartCallbackRegistrationsLocked() { +/// Make the three process-global Dart bridges inert. +/// +/// The caller must hold dart_callback_invocation_mutex. This performs no +/// blocking work, so it stays safe to run on a platform/UI thread. +static void clearDartCallbackPointersLocked() { dartVoiceEndedCallback.store(nullptr, std::memory_order_release); dartFileLoadedCallback.store(nullptr, std::memory_order_release); dartStateChangedCallback.store(nullptr, std::memory_order_release); - dartCallbackOwnerEngineId = kNoDartCallbackOwnerEngineId; + dartCallbackOwnerEngineId = kNoEngineId; +} +/// Additionally clear the per-BufferStream Dart callbacks. +/// +/// The caller must hold init_deinit_mutex, which owns the `player` unique_ptr +/// that dispose() resets. BufferStream::clearDartCallbacks() is a pair of +/// atomic stores, so it does not need dart_callback_invocation_mutex. +static void clearPlayerDartCallbackRegistrationsLocked() { if (player.get() != nullptr) { player.get()->clearDartCallbackRegistrations(); } } +static void clearDartCallbackRegistrationsLocked() { + clearDartCallbackPointersLocked(); + clearPlayerDartCallbackRegistrationsLocked(); +} + +/// Clear the per-BufferStream Dart callbacks once init_deinit_mutex becomes +/// available, without making the caller wait for it. +/// +/// Used when the caller runs on a thread that must not block (the Android +/// platform thread) and the mutex is currently held by an unrelated operation +/// such as loadFile() or initEngine(). The worker captures the engine lifecycle +/// claim and gives up if a new engine has claimed the native engine meanwhile, +/// so it can never erase callbacks that a replacement engine has just +/// registered. +static void queuePlayerDartCallbackClear() { + const EngineLifecycleClaim claim = currentEngineLifecycleClaim(); + + try { + std::thread([claim]() { + std::lock_guard guard(init_deinit_mutex); + + if (!engineLifecycleClaimIsCurrent(claim)) + return; + + clearPlayerDartCallbackRegistrationsLocked(); + }).detach(); + } catch (...) { + // Best effort. The global bridges are already inert, which is what stops + // native code invoking a dead callable; a teardown or a later init() + // still clears the BufferStream callbacks. + } +} + FFI_PLUGIN_EXPORT void clearDartCallbackRegistrations() { std::lock_guard guard_init(init_deinit_mutex); std::lock_guard guard_load(loadMutex); @@ -238,16 +347,49 @@ FFI_PLUGIN_EXPORT void clearDartCallbackRegistrations() { clearDartCallbackRegistrationsLocked(); } +/// Invalidate the Dart bridges owned by [engine_id] when its FlutterEngine is +/// detached. Returns false when a different engine owns the current +/// registration, so a detaching engine never clears another one's callbacks. +/// +/// This runs on the Android platform (UI) thread, so unlike +/// clearDartCallbackRegistrations() it must never wait behind a device +/// operation. init_deinit_mutex is held for the whole of dispose(), which joins +/// the lifecycle scheduler and can therefore inherit a stalled +/// ma_device_stop(); blocking on it here would ANR the app at engine teardown. +/// Only dart_callback_invocation_mutex is taken unconditionally — it is never +/// held across a device operation. FFI_PLUGIN_EXPORT bool clearDartCallbackRegistrationsForEngine(int64_t engine_id) { - std::lock_guard guard_init(init_deinit_mutex); - std::lock_guard guard_load(loadMutex); - std::lock_guard callbackGuard(dart_callback_invocation_mutex); + { + std::lock_guard callbackGuard(dart_callback_invocation_mutex); - if (dartCallbackOwnerEngineId != engine_id) - return false; + if (dartCallbackOwnerEngineId != engine_id) + return false; - clearDartCallbackRegistrationsLocked(); + clearDartCallbackPointersLocked(); + } + + // The BufferStream callbacks live inside Player, so reaching them needs + // init_deinit_mutex, which owns the `player` unique_ptr that dispose() + // resets. Doing this outside dart_callback_invocation_mutex also keeps the + // lock order consistent with disposeSound(), which holds sounds_mutex across + // soloud.stop() and reaches voiceEndedCallback(). + // + // Take the fast path when the mutex happens to be free, but never treat a + // failed try_lock as "someone else will handle it": init_deinit_mutex is held + // by loadFile(), loadMem(), initEngine(), changeDevice(), the device + // start/stop calls and even isInited() — none of which clear these + // callbacks. Hand the work to a worker that can afford to wait instead. + { + std::unique_lock guard_init(init_deinit_mutex, + std::try_to_lock); + if (guard_init.owns_lock()) { + clearPlayerDartCallbackRegistrationsLocked(); + return true; + } + } + + queuePlayerDartCallbackClear(); return true; } @@ -276,8 +418,23 @@ FFI_PLUGIN_EXPORT bool areXiphLibsAvailable() { /// 2=stereo, 4=quad, 6=5.1, 8=7.1. /// /// Returns [PlayerErrors.noError] if success. -FFI_PLUGIN_EXPORT void prepareEngineInit() { +/// [owner_engine_id] is the FlutterEngine that will own the engine this +/// initialization creates, or -1 on platforms with no engine-lifecycle hooks. +FFI_PLUGIN_EXPORT void prepareEngineInit(int64_t owner_engine_id) { + std::lock_guard guard(engine_lifecycle_mutex); + // Lowered under the same mutex that publishes the claim, so a teardown for + // the previous engine cannot raise it again after this point. See + // tryBeginEngineTeardown(). engine_shutdown_requested.store(false, std::memory_order_release); + // Claim the native engine for this FlutterEngine now, before initEngine() + // runs. Ownership must not wait for callback registration: initEngine() + // opens the audio device and can take seconds on Android, and Dart registers + // callbacks only after it returns. An engine destroyed during that window + // still has to be able to tear down what it just built. + nativeInitOwnerEngineId = owner_engine_id; + // Invalidate any teardown queued by a previous engine's detach so it cannot + // dispose the engine this initialization is about to create. + ++engineInitGeneration; } FFI_PLUGIN_EXPORT void requestEngineShutdown() { @@ -375,7 +532,6 @@ FFI_PLUGIN_EXPORT enum AudioDeviceState getAudioDeviceState() { // query never waits behind an initialization or lifecycle API call. return (AudioDeviceState)SoLoud::miniaudio_getAudioDeviceState(); } - /// Test-only hook that sends an interruption through miniaudio's normal /// notification callback. This is intentionally absent from the public API. FFI_PLUGIN_EXPORT void debugTriggerAudioInterruption(unsigned int began) { @@ -442,13 +598,8 @@ FFI_PLUGIN_EXPORT void freeListPlaybackDevices(char **devicesName, /// Must be called when there is no more need of the player or when closing the /// app /// -FFI_PLUGIN_EXPORT void dispose() { - // Preserve request ordering for asynchronous Dart init/deinit workers. - engine_shutdown_requested.store(true, std::memory_order_release); - - std::lock_guard guard(init_deinit_mutex); - std::lock_guard guard_load(loadMutex); - +/// Teardown body. The caller must hold init_deinit_mutex and loadMutex. +static void disposeLocked() { // Wait for any Dart callback currently executing, then make every bridge // inert. Do not retain the callback mutex while stopping devices, joining // threads, destroying sources, or resetting Player. @@ -458,6 +609,11 @@ FFI_PLUGIN_EXPORT void dispose() { clearDartCallbackRegistrationsLocked(); } + // Nothing is left for a FlutterEngine to own. A detach arriving after this + // finds no claim and correctly declines to tear anything down; the next + // prepareEngineInit() takes a fresh claim. + releaseEngineLifecycleClaim(); + if (player.get() == nullptr) return; @@ -469,6 +625,83 @@ FFI_PLUGIN_EXPORT void dispose() { analyzer = std::make_unique(256); } +FFI_PLUGIN_EXPORT void dispose() { + // Preserve request ordering for asynchronous Dart init/deinit workers. + engine_shutdown_requested.store(true, std::memory_order_release); + + std::lock_guard guard(init_deinit_mutex); + std::lock_guard guard_load(loadMutex); + + disposeLocked(); +} + +/// Tear the engine down because its owning FlutterEngine is being destroyed +/// while the process keeps running (the audio_service / add-to-app case). +/// +/// Without this the native engine stays initialized with a live output device +/// and a running scheduler after the last Dart code that could drive it is +/// gone. Returns false unless [engine_id] still owns the native engine. +/// +/// Ownership here is the *lifecycle* claim taken by prepareEngineInit(), not +/// dartCallbackOwnerEngineId. Gating on callback ownership would be wrong in +/// both directions: it is unset for the whole of an initialization -- so an +/// engine destroyed after initEngine() opened the device but before Dart +/// registered callbacks could not tear down what it had just built -- and it +/// still names the *previous* engine once a replacement has called +/// prepareEngineInit(), so a detaching engine would be accepted and would then +/// capture the replacement's already-bumped generation and dispose a live +/// engine. The generation cannot rescue either case: it is bumped when an +/// initialization starts, which is before this teardown reads it. +/// +/// The blocking teardown is handed to a detached worker: this is invoked from +/// the Android platform thread, which must never wait on a device operation. +FFI_PLUGIN_EXPORT bool requestEngineTeardownForEngine(int64_t engine_id) { + if (engine_id == kNoEngineId) + return false; + + // Retire this engine's callables first, whatever the lifecycle decision + // below turns out to be, and gated on callback ownership rather than the + // lifecycle claim. The isolate that created them is going away and invoking + // one afterwards is undefined behaviour, so this must not be conditional on + // also being allowed to dispose the engine. + // + // It is load-bearing that this runs before the early return: since the + // teardown hook replaced the plain clear on the Java detach path, this is the + // only thing that clears callables when a FlutterEngine is destroyed. An + // engine can legitimately own the callables without owning the lifecycle + // claim -- its initialization worker can win the mutex after a later engine + // has already claimed -- and gating the clear on the claim left that engine's + // callables live after its isolate died. + clearDartCallbackRegistrationsForEngine(engine_id); + + // A different engine has claimed the native engine, so it is live and owns + // its own teardown. An unclaimed engine means Dart already deinited cleanly, + // leaving nothing to dispose. + EngineLifecycleClaim claim; + if (!tryBeginEngineTeardown(engine_id, &claim)) + return false; + + try { + std::thread([claim]() { + std::lock_guard guard(init_deinit_mutex); + std::lock_guard guard_load(loadMutex); + + // A replacement engine claimed the native engine while this worker was + // waiting for the mutex. Its engine is live and must not be torn down. + if (!engineLifecycleClaimIsCurrent(claim)) + return; + + disposeLocked(); + }).detach(); + } catch (...) { + // Thread creation failed. The bridges are already inert, and the next + // init() still recovers by deiniting the stale engine itself. + return false; + } + + return true; +} + #if defined(__ANDROID__) extern "C" JNIEXPORT jboolean JNICALL Java_flutter_soloud_flutter_1soloud_FlutterSoloudPlugin_nativeClearDartCallbackRegistrationsForEngine( @@ -477,6 +710,14 @@ Java_flutter_soloud_flutter_1soloud_FlutterSoloudPlugin_nativeClearDartCallbackR ? JNI_TRUE : JNI_FALSE; } + +extern "C" JNIEXPORT jboolean JNICALL +Java_flutter_soloud_flutter_1soloud_FlutterSoloudPlugin_nativeRequestEngineTeardownForEngine( + JNIEnv *, jclass, jlong engine_id) { + return requestEngineTeardownForEngine(static_cast(engine_id)) + ? JNI_TRUE + : JNI_FALSE; +} #endif FFI_PLUGIN_EXPORT int isInited() { diff --git a/src/player.cpp b/src/player.cpp index a6f03927..c3c38ffc 100644 --- a/src/player.cpp +++ b/src/player.cpp @@ -1078,6 +1078,31 @@ PlayerErrors Player::performAudioDeviceStart() // Use the normal resume hook so iOS reactivates AVAudioSession before the // Audio Unit is restarted. SoLoud::result result = soloud.resume(); + if (result == SoLoud::SO_NO_ERROR) + return noError; + + // The start failed. Rebuild the device and try once more before giving up. + // + // The backend keeps the stream open across an idle stop, so a device that + // has been stopped for a while can be holding a stream the OS has since + // invalidated. On Android this is the common case: AAudio only reports a + // disconnect through the error callback of a *running* stream, so a stream + // that is torn down while stopped -- a route change, or the framework + // reclaiming resources from a backgrounded app -- is never rerouted, and + // the staleness only surfaces here, as AAudioStream_requestStart failing. + // + // Replacing the device recreates the stream against the current default + // output. Voices, loaded sources and filters all live in SoLoud rather than + // in the device, so playback resumes where it left off. + // + // Callers hold mDeviceLifecycleOperationMutex, so this cannot interleave + // with another device operation. One retry only: if a freshly built device + // will not start either, the failure is not staleness. + const SoLoud::result rebuilt = soloud.miniaudio_changeDevice(nullptr); + if (rebuilt != SoLoud::SO_NO_ERROR) + return unknownError; + + result = soloud.resume(); if (result != SoLoud::SO_NO_ERROR) return unknownError; return noError; @@ -1139,8 +1164,24 @@ PlayerErrors Player::startAudioDevice() { std::lock_guard operationLock( mDeviceLifecycleOperationMutex); - if (mInterruptionActive.load(std::memory_order_acquire)) - return noError; + + // An explicit start is an authoritative request from the app, so it + // clears the interruption latch rather than being suppressed by it. + // + // Returning noError here without touching the device made this API + // able to report success while leaving the device stopped, and the + // latch can be stuck: iOS does not reliably deliver + // AVAudioSessionInterruptionTypeEnded -- notably when the interruption + // ends while the app is backgrounded or suspended -- and the flag is + // otherwise only cleared by init()/deinit(). Once missed, every later + // start was a silent no-op for the lifetime of the engine. + // + // If an interruption really is still in force the OS refuses to + // activate the session and the start below fails, which surfaces a real + // error instead of a false success. A genuine new interruption arriving + // afterwards sets the flag again through the normal callback. + mInterruptionActive.store(false, std::memory_order_release); + // Cancel a stale delayed idle stop before prewarming the device. invalidatePendingDeviceRequest(); result = performAudioDeviceStart();