From fd7dafd6686f39ed5b4fe3b83bf62a257570e5f9 Mon Sep 17 00:00:00 2001 From: Colton Date: Wed, 22 Jul 2026 23:53:55 -0400 Subject: [PATCH 01/13] clear callbacks --- .../flutter_soloud/FlutterSoloudPlugin.kt | 22 ++++++++ lib/src/bindings/bindings_player_ffi.dart | 3 ++ pubspec.yaml | 2 + src/audiobuffer/audiobuffer.cpp | 23 ++++---- src/bindings.cpp | 53 +++++++++++++------ src/dart_callback_lifecycle.h | 7 +++ 6 files changed, 84 insertions(+), 26 deletions(-) create mode 100644 android/src/main/kotlin/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.kt create mode 100644 src/dart_callback_lifecycle.h diff --git a/android/src/main/kotlin/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.kt b/android/src/main/kotlin/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.kt new file mode 100644 index 00000000..1b2c87bb --- /dev/null +++ b/android/src/main/kotlin/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.kt @@ -0,0 +1,22 @@ +package flutter.soloud.flutter_soloud + +import io.flutter.embedding.engine.plugins.FlutterPlugin + +class FlutterSoloudPlugin : FlutterPlugin { + companion object { + init { + System.loadLibrary("flutter_soloud_plugin") + } + + @JvmStatic + private external fun nativeClearDartCallbackRegistrations() + } + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + // Dart continues using FFI; no platform channel is required. + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + nativeClearDartCallbackRegistrations() + } +} diff --git a/lib/src/bindings/bindings_player_ffi.dart b/lib/src/bindings/bindings_player_ffi.dart index dbfc21b3..ac382c69 100644 --- a/lib/src/bindings/bindings_player_ffi.dart +++ b/lib/src/bindings/bindings_player_ffi.dart @@ -244,6 +244,9 @@ class FlutterSoLoudFfi extends FlutterSoLoud { @override void disposeNativeCallables() { + // Native clearing is a synchronous barrier. It returns only after any + // callback trampoline invocation already in progress has completed. + clearDartCallbackRegistrations(); _disposeAllBufferStreamCallbacks(); nativeVoiceEndedCallable?.close(); nativeVoiceEndedCallable = null; diff --git a/pubspec.yaml b/pubspec.yaml index 94a8a035..293b6d26 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,8 @@ flutter: plugin: platforms: android: + package: flutter.soloud.flutter_soloud + pluginClass: FlutterSoloudPlugin ffiPlugin: true ios: ffiPlugin: true diff --git a/src/audiobuffer/audiobuffer.cpp b/src/audiobuffer/audiobuffer.cpp index 69b05beb..15f14cec 100644 --- a/src/audiobuffer/audiobuffer.cpp +++ b/src/audiobuffer/audiobuffer.cpp @@ -11,6 +11,7 @@ #include "../soloud_common.h" #include "audiobuffer.h" +#include "../dart_callback_lifecycle.h" #include "metadata_ffi.h" #ifdef __EMSCRIPTEN__ @@ -457,11 +458,9 @@ void BufferStream::checkBuffering(unsigned int afterAddingBytesCount) { } void BufferStream::callOnMetadataCallback(AudioMetadata &metadata) { - auto metadataCb = mOnMetadataCallback.load(); - if (metadataCb != nullptr) { - AudioMetadataFFI ffi = this->convertMetadataToFFI(metadata); - // metadata.debug(); #ifdef __EMSCRIPTEN__ + AudioMetadataFFI ffi = this->convertMetadataToFFI(metadata); + // metadata.debug(); // Call the Dart callback stored on globalThis, if it exists. // The `dartOnMetadataCallback_$hash` function is created in // `setBufferStream()` in `bindings_player_web.dart` and it's @@ -478,15 +477,17 @@ void BufferStream::callOnMetadataCallback(AudioMetadata &metadata) { }, &ffi, mParent->soundHash); #else + std::lock_guard callbackGuard(dart_callback_invocation_mutex); + auto metadataCb = mOnMetadataCallback.load(std::memory_order_acquire); + if (metadataCb != nullptr) { + const AudioMetadataFFI ffi = this->convertMetadataToFFI(metadata); metadataCb(ffi); -#endif } +#endif } void BufferStream::callOnBufferingCallback(bool isBuffering, unsigned int handle, double time) { - auto bufferingCb = mOnBufferingCallback.load(); - if (bufferingCb != nullptr) { #ifdef __EMSCRIPTEN__ // Call the Dart callback stored on globalThis, if it exists. // The `dartOnBufferingCallback_$hash` function is created in @@ -506,15 +507,17 @@ void BufferStream::callOnBufferingCallback(bool isBuffering, }, isBuffering, handle, time, mParent->soundHash); #else + std::lock_guard callbackGuard(dart_callback_invocation_mutex); + auto bufferingCb = mOnBufferingCallback.load(std::memory_order_acquire); + if (bufferingCb != nullptr) bufferingCb(isBuffering, handle, time); #endif - } mIsBuffering = isBuffering; } void BufferStream::clearDartCallbacks() { - mOnBufferingCallback.store(nullptr); - mOnMetadataCallback.store(nullptr); + mOnBufferingCallback.store(nullptr, std::memory_order_release); + mOnMetadataCallback.store(nullptr, std::memory_order_release); } BufferingType BufferStream::getBufferingType() { return mBuffer.bufferingType; } diff --git a/src/bindings.cpp b/src/bindings.cpp index e62d3def..bfe99c2b 100644 --- a/src/bindings.cpp +++ b/src/bindings.cpp @@ -5,6 +5,7 @@ #include "soloud/include/soloud_internal.h" #include "soloud_thread.h" #include "waveform/waveform.h" +#include "dart_callback_lifecycle.h" #ifndef SOLOUD_COMMON_H #include "soloud_common.h" @@ -18,8 +19,11 @@ #include #include #include +#include #include +std::mutex dart_callback_invocation_mutex; + #ifdef __cplusplus extern "C" { #endif @@ -133,20 +137,22 @@ FFI_PLUGIN_EXPORT void voiceEndedCallback(unsigned int *handle) { sendToWorker("voiceEndedCallback", *handle); #endif - // The `dartVoiceEndedCallback` is not set on Web. - // Snapshot the atomic pointer so it can't be nulled between check and call. - auto voiceEndedCb = dartVoiceEndedCallback.load(); - if (voiceEndedCb == nullptr) - return; // So, if the handle was already found before (henche the handle is not // found), the callback to Dart has been already called. If this is the fist // time this handle is found, the callback to Dart must be called. if (!isHandleFound) return; + +#ifndef __EMSCRIPTEN__ + std::lock_guard callbackGuard(dart_callback_invocation_mutex); + auto voiceEndedCb = dartVoiceEndedCallback.load(std::memory_order_acquire); + if (voiceEndedCb == nullptr) + return; // [n] pointer must be deleted in Dart. unsigned int *n = (unsigned int *)malloc(sizeof(unsigned int)); *n = *handle; voiceEndedCb(n); +#endif } /// Requests a device-idle evaluation after SoLoud stops or pauses a voice. @@ -159,7 +165,8 @@ FFI_PLUGIN_EXPORT void voiceInactiveCallback() { /// The callback to monitor when a file is loaded. void fileLoadedCallback(enum PlayerErrors error, char *completeFileName, unsigned int *hash, uint64_t counter) { - auto fileLoadedCb = dartFileLoadedCallback.load(); + std::lock_guard callbackGuard(dart_callback_invocation_mutex); + auto fileLoadedCb = dartFileLoadedCallback.load(std::memory_order_acquire); if (fileLoadedCb == nullptr) return; // [e,name,n] pointers must be deleted on Dart. @@ -174,7 +181,8 @@ FFI_PLUGIN_EXPORT void voiceInactiveCallback() { } void stateChangedCallback(unsigned int state) { - auto stateChangedCb = dartStateChangedCallback.load(); + std::lock_guard callbackGuard(dart_callback_invocation_mutex); + auto stateChangedCb = dartStateChangedCallback.load(std::memory_order_acquire); if (stateChangedCb == nullptr) return; PlayerStateEvents *type = (PlayerStateEvents *)malloc(sizeof(PlayerStateEvents)); *type = (PlayerStateEvents)state; @@ -187,18 +195,20 @@ FFI_PLUGIN_EXPORT void setDartEventCallback(dartVoiceEndedCallback_t voice_ended_callback, dartFileLoadedCallback_t file_loaded_callback, dartStateChangedCallback_t state_changed_callback) { - dartVoiceEndedCallback.store(voice_ended_callback); - dartFileLoadedCallback.store(file_loaded_callback); - dartStateChangedCallback.store(state_changed_callback); + std::lock_guard callbackGuard(dart_callback_invocation_mutex); + dartVoiceEndedCallback.store(voice_ended_callback, std::memory_order_release); + dartFileLoadedCallback.store(file_loaded_callback, std::memory_order_release); + dartStateChangedCallback.store(state_changed_callback, std::memory_order_release); } FFI_PLUGIN_EXPORT void clearDartCallbackRegistrations() { std::lock_guard guard_init(init_deinit_mutex); std::lock_guard guard_load(loadMutex); + std::lock_guard callbackGuard(dart_callback_invocation_mutex); - dartVoiceEndedCallback.store(nullptr); - dartFileLoadedCallback.store(nullptr); - dartStateChangedCallback.store(nullptr); + dartVoiceEndedCallback.store(nullptr, std::memory_order_release); + dartFileLoadedCallback.store(nullptr, std::memory_order_release); + dartStateChangedCallback.store(nullptr, std::memory_order_release); if (player.get() != nullptr) { player.get()->clearDartCallbackRegistrations(); @@ -406,9 +416,10 @@ FFI_PLUGIN_EXPORT void dispose() { // Make every native-to-Dart bridge inert before Player::dispose() stops // voices and destroys sources. Dart keeps its NativeCallables alive until // this off-isolate native teardown has completed. - dartVoiceEndedCallback = nullptr; - dartFileLoadedCallback = nullptr; - dartStateChangedCallback = nullptr; + std::lock_guard callbackGuard(dart_callback_invocation_mutex); + dartVoiceEndedCallback.store(nullptr, std::memory_order_release); + dartFileLoadedCallback.store(nullptr, std::memory_order_release); + dartStateChangedCallback.store(nullptr, std::memory_order_release); if (player.get() == nullptr) return; player.get()->dispose(); @@ -419,6 +430,16 @@ FFI_PLUGIN_EXPORT void dispose() { analyzer = std::make_unique(256); } +#if defined(__ANDROID__) +#include + +extern "C" JNIEXPORT void JNICALL +Java_flutter_soloud_flutter_1soloud_FlutterSoloudPlugin_nativeClearDartCallbackRegistrations( + JNIEnv *, jclass) { + clearDartCallbackRegistrations(); +} +#endif + FFI_PLUGIN_EXPORT int isInited() { std::lock_guard guard(init_deinit_mutex); if (player.get() == nullptr) diff --git a/src/dart_callback_lifecycle.h b/src/dart_callback_lifecycle.h new file mode 100644 index 00000000..4c1dccaf --- /dev/null +++ b/src/dart_callback_lifecycle.h @@ -0,0 +1,7 @@ +#pragma once + +#include + +// Shared gate for every retained Dart callback pointer. The gate must be held +// while loading and invoking a callback, and while clearing registrations. +extern std::mutex dart_callback_invocation_mutex; From 2a9f644f2f5de6944f7322b6ff54db3dd3bcc98c Mon Sep 17 00:00:00 2001 From: Colton Date: Thu, 23 Jul 2026 00:18:57 -0400 Subject: [PATCH 02/13] preserve callback --- android/build.gradle | 4 +- .../flutter_soloud/FlutterSoloudPlugin.kt | 15 +++++- lib/src/bindings/bindings_player_ffi.dart | 14 +++++ src/bindings.cpp | 51 +++++++++++++++---- src/ffi_gen_tmp.h | 13 +++++ src/player.cpp | 7 +-- 6 files changed, 85 insertions(+), 19 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 81bfa853..9982aaab 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -97,7 +97,7 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } } diff --git a/android/src/main/kotlin/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.kt b/android/src/main/kotlin/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.kt index 1b2c87bb..a0f3e105 100644 --- a/android/src/main/kotlin/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.kt +++ b/android/src/main/kotlin/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.kt @@ -9,14 +9,25 @@ class FlutterSoloudPlugin : FlutterPlugin { } @JvmStatic - private external fun nativeClearDartCallbackRegistrations() + private external fun nativeClearDartCallbackRegistrationsForEngine( + engineId: Long, + ): Boolean } + private var engineId: Long? = null + + @Suppress("DEPRECATION") override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { // Dart continues using FFI; no platform channel is required. + engineId = binding.flutterEngine.engineId } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - nativeClearDartCallbackRegistrations() + val detachedEngineId = engineId + engineId = null + + if (detachedEngineId != null) { + nativeClearDartCallbackRegistrationsForEngine(detachedEngineId) + } } } diff --git a/lib/src/bindings/bindings_player_ffi.dart b/lib/src/bindings/bindings_player_ffi.dart index ac382c69..463545d2 100644 --- a/lib/src/bindings/bindings_player_ffi.dart +++ b/lib/src/bindings/bindings_player_ffi.dart @@ -5,8 +5,10 @@ // ignore_for_file: omit_local_variable_types,public_member_api_docs import 'dart:ffi' as ffi; +import 'dart:io' as io; import 'dart:isolate'; import 'dart:typed_data'; +import 'dart:ui' as ui; import 'package:ffi/ffi.dart'; import 'package:flutter_soloud/src/bindings/audio_data.dart'; @@ -263,6 +265,15 @@ class FlutterSoLoudFfi extends FlutterSoLoud { @override Future setDartEventCallbacks() async { + final engineId = ui.PlatformDispatcher.instance.engineId; + + if (io.Platform.isAndroid && engineId == null) { + throw StateError( + 'PlatformDispatcher.engineId was null while registering ' + 'flutter_soloud Android callbacks.', + ); + } + // Create a NativeCallable for the Dart functions nativeVoiceEndedCallable = ffi.NativeCallable.listener( @@ -281,6 +292,7 @@ class FlutterSoLoudFfi extends FlutterSoLoud { nativeVoiceEndedCallable!.nativeFunction, nativeFileLoadedCallable!.nativeFunction, nativeStateChangedCallable!.nativeFunction, + engineId ?? -1, ); } @@ -291,6 +303,7 @@ class FlutterSoLoudFfi extends FlutterSoLoud { DartVoiceEndedCallbackT, DartFileLoadedCallbackT, DartStateChangedCallbackT, + ffi.Int64, ) > >('setDartEventCallback'); @@ -300,6 +313,7 @@ class FlutterSoLoudFfi extends FlutterSoLoud { DartVoiceEndedCallbackT, DartFileLoadedCallbackT, DartStateChangedCallbackT, + int, ) >(); diff --git a/src/bindings.cpp b/src/bindings.cpp index bfe99c2b..45b4fc6a 100644 --- a/src/bindings.cpp +++ b/src/bindings.cpp @@ -16,6 +16,7 @@ #endif #include +#include #include #include #include @@ -24,6 +25,13 @@ std::mutex dart_callback_invocation_mutex; +namespace { +constexpr int64_t kNoDartCallbackOwnerEngineId = -1; + +// Protected by dart_callback_invocation_mutex. +int64_t dartCallbackOwnerEngineId = kNoDartCallbackOwnerEngineId; +} + #ifdef __cplusplus extern "C" { #endif @@ -194,27 +202,47 @@ void stateChangedCallback(unsigned int state) { FFI_PLUGIN_EXPORT void setDartEventCallback(dartVoiceEndedCallback_t voice_ended_callback, dartFileLoadedCallback_t file_loaded_callback, - dartStateChangedCallback_t state_changed_callback) { + dartStateChangedCallback_t state_changed_callback, + int64_t owner_engine_id) { std::lock_guard callbackGuard(dart_callback_invocation_mutex); dartVoiceEndedCallback.store(voice_ended_callback, std::memory_order_release); dartFileLoadedCallback.store(file_loaded_callback, std::memory_order_release); dartStateChangedCallback.store(state_changed_callback, std::memory_order_release); + dartCallbackOwnerEngineId = owner_engine_id; } -FFI_PLUGIN_EXPORT void clearDartCallbackRegistrations() { - std::lock_guard guard_init(init_deinit_mutex); - std::lock_guard guard_load(loadMutex); - std::lock_guard callbackGuard(dart_callback_invocation_mutex); - +static void clearDartCallbackRegistrationsLocked() { dartVoiceEndedCallback.store(nullptr, std::memory_order_release); dartFileLoadedCallback.store(nullptr, std::memory_order_release); dartStateChangedCallback.store(nullptr, std::memory_order_release); + dartCallbackOwnerEngineId = kNoDartCallbackOwnerEngineId; if (player.get() != nullptr) { player.get()->clearDartCallbackRegistrations(); } } +FFI_PLUGIN_EXPORT void clearDartCallbackRegistrations() { + std::lock_guard guard_init(init_deinit_mutex); + std::lock_guard guard_load(loadMutex); + std::lock_guard callbackGuard(dart_callback_invocation_mutex); + + clearDartCallbackRegistrationsLocked(); +} + +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); + + if (dartCallbackOwnerEngineId != engine_id) + return false; + + clearDartCallbackRegistrationsLocked(); + return true; +} + ////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////// @@ -420,6 +448,7 @@ FFI_PLUGIN_EXPORT void dispose() { dartVoiceEndedCallback.store(nullptr, std::memory_order_release); dartFileLoadedCallback.store(nullptr, std::memory_order_release); dartStateChangedCallback.store(nullptr, std::memory_order_release); + dartCallbackOwnerEngineId = kNoDartCallbackOwnerEngineId; if (player.get() == nullptr) return; player.get()->dispose(); @@ -433,10 +462,12 @@ FFI_PLUGIN_EXPORT void dispose() { #if defined(__ANDROID__) #include -extern "C" JNIEXPORT void JNICALL -Java_flutter_soloud_flutter_1soloud_FlutterSoloudPlugin_nativeClearDartCallbackRegistrations( - JNIEnv *, jclass) { - clearDartCallbackRegistrations(); +extern "C" JNIEXPORT jboolean JNICALL +Java_flutter_soloud_flutter_1soloud_FlutterSoloudPlugin_nativeClearDartCallbackRegistrationsForEngine( + JNIEnv *, jclass, jlong engine_id) { + return clearDartCallbackRegistrationsForEngine(static_cast(engine_id)) + ? JNI_TRUE + : JNI_FALSE; } #endif diff --git a/src/ffi_gen_tmp.h b/src/ffi_gen_tmp.h index fc7ba007..b9c8ad60 100644 --- a/src/ffi_gen_tmp.h +++ b/src/ffi_gen_tmp.h @@ -20,6 +20,19 @@ #define FFI_PLUGIN_EXPORT +typedef void (*dartVoiceEndedCallback_t)(unsigned int *); +typedef void (*dartFileLoadedCallback_t)(enum PlayerErrors *, + char *, + unsigned int *, + uint64_t *); +typedef void (*dartStateChangedCallback_t)(enum PlayerStateEvents *); + +FFI_PLUGIN_EXPORT void setDartEventCallback( + dartVoiceEndedCallback_t voiceEnded, + dartFileLoadedCallback_t fileLoaded, + dartStateChangedCallback_t stateChanged, + int64_t ownerEngineId); + //--------------------- copy here the new functions to generate /// Create a new mixing bus. diff --git a/src/player.cpp b/src/player.cpp index 26c1144c..60c157ba 100644 --- a/src/player.cpp +++ b/src/player.cpp @@ -196,6 +196,8 @@ void Player::dispose() { // Unregister callbacks before stopping voices. In particular, keep stale // Dart callback pointers from being used while teardown destroys sources. clearDartCallbackRegistrations(); + setVoiceEndedCallback(nullptr); + setStateChangedCallback(nullptr); setVoiceInactiveCallback(nullptr); // Player::dispose() is the sole owner of native sound destruction during @@ -1573,11 +1575,6 @@ void Player::disposeAllSound() void Player::clearDartCallbackRegistrations() { - // The voice-inactive callback is native lifecycle support, not a Dart - // callback, and must remain registered across a Dart hot restart. - setVoiceEndedCallback(nullptr); - setStateChangedCallback(nullptr); - std::lock_guard lock(sounds_mutex); for (auto &sound : sounds) { From 4fb5ddd0222d459a4007e2437240fa32d6a6529b Mon Sep 17 00:00:00 2001 From: Colton Date: Thu, 23 Jul 2026 16:59:56 -0400 Subject: [PATCH 03/13] Return errors from voice operations --- lib/src/bindings/bindings_player.dart | 6 +-- lib/src/bindings/bindings_player_ffi.dart | 27 +++++----- lib/src/bindings/bindings_player_web.dart | 12 ++--- lib/src/bindings/js_extension.dart | 6 +-- lib/src/soloud.dart | 32 ++++++++++-- src/bindings.cpp | 36 ++++++++----- src/player.cpp | 64 +++++++++++++++++------ src/player.h | 10 ++-- 8 files changed, 133 insertions(+), 60 deletions(-) diff --git a/lib/src/bindings/bindings_player.dart b/lib/src/bindings/bindings_player.dart index f39ff98f..2a13414b 100644 --- a/lib/src/bindings/bindings_player.dart +++ b/lib/src/bindings/bindings_player.dart @@ -330,14 +330,14 @@ abstract class FlutterSoLoud { /// /// [handle] the sound handle. @mustBeOverridden - void pauseSwitch(SoundHandle handle); + PlayerErrors pauseSwitch(SoundHandle handle); /// Pause or unpause already loaded sound identified by [handle]. /// /// [handle] the sound handle. /// [pause] the new state. @mustBeOverridden - void setPause(SoundHandle handle, int pause); + PlayerErrors setPause(SoundHandle handle, int pause); /// Gets the pause state. /// @@ -402,7 +402,7 @@ abstract class FlutterSoLoud { /// /// [handle] the sound handle. @mustBeOverridden - void stop(SoundHandle handle); + PlayerErrors stop(SoundHandle handle); /// Stop all handles of the already loaded sound identified /// by [soundHash] and dispose it. diff --git a/lib/src/bindings/bindings_player_ffi.dart b/lib/src/bindings/bindings_player_ffi.dart index 463545d2..2d067680 100644 --- a/lib/src/bindings/bindings_player_ffi.dart +++ b/lib/src/bindings/bindings_player_ffi.dart @@ -1035,26 +1035,28 @@ class FlutterSoLoudFfi extends FlutterSoLoud { >(); @override - void pauseSwitch(SoundHandle handle) { - return _pauseSwitch(handle.id); + PlayerErrors pauseSwitch(SoundHandle handle) { + final result = _pauseSwitch(handle.id); + return PlayerErrors.values[result]; } late final _pauseSwitchPtr = - _lookup>( + _lookup>( 'pauseSwitch', ); - late final _pauseSwitch = _pauseSwitchPtr.asFunction(); + late final _pauseSwitch = _pauseSwitchPtr.asFunction(); @override - void setPause(SoundHandle handle, int pause) { - return _setPause(handle.id, pause); + PlayerErrors setPause(SoundHandle handle, int pause) { + final result = _setPause(handle.id, pause); + return PlayerErrors.values[result]; } late final _setPausePtr = - _lookup>( + _lookup>( 'setPause', ); - late final _setPause = _setPausePtr.asFunction(); + late final _setPause = _setPausePtr.asFunction(); @override bool getPause(SoundHandle handle) { @@ -1163,13 +1165,14 @@ class FlutterSoLoudFfi extends FlutterSoLoud { >(); @override - void stop(SoundHandle handle) { - return _stop(handle.id); + PlayerErrors stop(SoundHandle handle) { + final result = _stop(handle.id); + return PlayerErrors.values[result]; } late final _stopPtr = - _lookup>('stop'); - late final _stop = _stopPtr.asFunction(); + _lookup>('stop'); + late final _stop = _stopPtr.asFunction(); @override void disposeSound(SoundHash soundHash) { diff --git a/lib/src/bindings/bindings_player_web.dart b/lib/src/bindings/bindings_player_web.dart index 9c0ae037..74af5db4 100644 --- a/lib/src/bindings/bindings_player_web.dart +++ b/lib/src/bindings/bindings_player_web.dart @@ -473,13 +473,13 @@ class FlutterSoLoudWeb extends FlutterSoLoud { } @override - void pauseSwitch(SoundHandle handle) { - return wasmPauseSwitch(handle.id); + PlayerErrors pauseSwitch(SoundHandle handle) { + return PlayerErrors.values[wasmPauseSwitch(handle.id)]; } @override - void setPause(SoundHandle handle, int pause) { - return wasmSetPause(handle.id, pause); + PlayerErrors setPause(SoundHandle handle, int pause) { + return PlayerErrors.values[wasmSetPause(handle.id, pause)]; } @override @@ -536,8 +536,8 @@ class FlutterSoLoudWeb extends FlutterSoLoud { } @override - void stop(SoundHandle handle) { - return wasmStop(handle.id); + PlayerErrors stop(SoundHandle handle) { + return PlayerErrors.values[wasmStop(handle.id)]; } @override diff --git a/lib/src/bindings/js_extension.dart b/lib/src/bindings/js_extension.dart index f9486301..fba43603 100644 --- a/lib/src/bindings/js_extension.dart +++ b/lib/src/bindings/js_extension.dart @@ -176,10 +176,10 @@ external void wasmSetWaveform(int soundHash, int newWaveform); external int wasmSpeechText(int textToSpeechPtr, int handlePtr); @JS('Module_soloud._pauseSwitch') -external void wasmPauseSwitch(int handle); +external int wasmPauseSwitch(int handle); @JS('Module_soloud._setPause') -external void wasmSetPause(int handle, int pause); +external int wasmSetPause(int handle, int pause); @JS('Module_soloud._getPause') external int wasmGetPause(int handle); @@ -207,7 +207,7 @@ external int wasmPlay( ); @JS('Module_soloud._stop') -external void wasmStop(int handle); +external int wasmStop(int handle); @JS('Module_soloud._disposeSound') external void wasmDisposeSound(int soundHash); diff --git a/lib/src/soloud.dart b/lib/src/soloud.dart index 9ae46d94..4741dd8d 100644 --- a/lib/src/soloud.dart +++ b/lib/src/soloud.dart @@ -1770,7 +1770,12 @@ interface class SoLoud { if (!isInitialized) { throw const SoLoudNotInitializedException(); } - _controller.soLoudFFI.pauseSwitch(handle); + + final error = _controller.soLoudFFI.pauseSwitch(handle); + if (error != PlayerErrors.noError) { + _logPlayerError(error, from: 'pauseSwitch()'); + throw SoLoudCppException.fromPlayerError(error); + } } /// Pause or unpause a currently playing sound identified by [handle]. @@ -1780,7 +1785,16 @@ interface class SoLoud { if (!isInitialized) { throw const SoLoudNotInitializedException(); } - _controller.soLoudFFI.setPause(handle, pause ? 1 : 0); + + final error = _controller.soLoudFFI.setPause( + handle, + pause ? 1 : 0, + ); + + if (error != PlayerErrors.noError) { + _logPlayerError(error, from: 'setPause()'); + throw SoLoudCppException.fromPlayerError(error); + } } /// Gets the pause state of a currently playing sound identified by [handle]. @@ -1862,7 +1876,19 @@ interface class SoLoud { ); completer.complete(); } else { - _controller.soLoudFFI.stop(handle); + final error = _controller.soLoudFFI.stop(handle); + + if (error == PlayerErrors.soundHandleNotFound) { + // The handle ended between the Dart validity check and native stop. + // stop() remains idempotent. + if (!completer.isCompleted) { + completer.complete(); + } + } else if (error != PlayerErrors.noError) { + voiceEndedCompleters.remove(handle); + _logPlayerError(error, from: 'stop()'); + throw SoLoudCppException.fromPlayerError(error); + } } return completer.future diff --git a/src/bindings.cpp b/src/bindings.cpp index 45b4fc6a..132df9c6 100644 --- a/src/bindings.cpp +++ b/src/bindings.cpp @@ -823,22 +823,22 @@ FFI_PLUGIN_EXPORT enum PlayerErrors speechText(char *textToSpeech, /// Switch pause state for an already loaded sound identified by [handle] /// /// [handle] the sound handle -FFI_PLUGIN_EXPORT void pauseSwitch(unsigned int handle) { - if (player.get() == nullptr || !player.get()->isInited() || - !player.get()->isValidHandle(handle)) - return; - player.get()->pauseSwitch(handle); +FFI_PLUGIN_EXPORT enum PlayerErrors pauseSwitch(unsigned int handle) { + if (player.get() == nullptr) + return PlayerErrors::backendNotInited; + + return player.get()->pauseSwitch(handle); } /// Pause or unpause already loaded sound identified by [handle] /// /// [handle] the sound handle /// [pause] the sound handle -FFI_PLUGIN_EXPORT void setPause(unsigned int handle, bool pause) { - if (player.get() == nullptr || !player.get()->isInited() || - !player.get()->isValidHandle(handle)) - return; - player.get()->setPause(handle, pause); +FFI_PLUGIN_EXPORT enum PlayerErrors setPause(unsigned int handle, bool pause) { + if (player.get() == nullptr) + return PlayerErrors::backendNotInited; + + return player.get()->setPause(handle, pause); } /// Gets the pause state @@ -920,11 +920,19 @@ FFI_PLUGIN_EXPORT enum PlayerErrors play(unsigned int soundHash, unsigned int bu /// Stop already loaded sound identified by [handle] and clear it /// /// [handle] -FFI_PLUGIN_EXPORT void stop(unsigned int handle) { - if (player.get() == nullptr || !player.get()->isInited()) - return; - player.get()->stop(handle); +FFI_PLUGIN_EXPORT enum PlayerErrors stop(unsigned int handle) { + if (player.get() == nullptr) + return PlayerErrors::backendNotInited; + + const PlayerErrors result = player.get()->stop(handle); + if (result != PlayerErrors::noError) + return result; + + // Preserve the existing fallback. voiceEndedCallback() is idempotent when + // SoLoud has already removed the handle and emitted the callback. voiceEndedCallback(&handle); + + return PlayerErrors::noError; } /// Stop all handles of the already loaded sound identified by [hash] and diff --git a/src/player.cpp b/src/player.cpp index 60c157ba..b27e716c 100644 --- a/src/player.cpp +++ b/src/player.cpp @@ -851,29 +851,53 @@ void Player::setWaveformSuperwave(unsigned int soundHash, bool superwave) static_cast(s->sound.get())->setSuperWave(superwave); } -void Player::pauseSwitch(unsigned int handle) -{ - setPause(handle, !soloud.getPause(handle)); -} - -void Player::setPause(unsigned int handle, bool pause) +void Player::applyPauseState(unsigned int handle, bool pause) { soloud.setPause(handle, pause); if (!pause) { - // Mutate the voice first. Only a handle that is still valid after the - // mutation is allowed to request device startup. - if (isValidHandle(handle)) - resumeEngine(); + // Preserve the current behavior: unpausing queues device startup on + // the lifecycle scheduler instead of starting the device inline. + resumeEngine(); return; } - // When pausing, check if there are any remaining active voices. If no - // voices are active, the scheduler applies the configured idle timeout. evaluateAudioDeviceIdle(); } +PlayerErrors Player::setPause(unsigned int handle, bool pause) +{ + if (!mInited.load(std::memory_order_acquire) || + !mLifecycleRequestsAccepted.load(std::memory_order_acquire)) + { + return PlayerErrors::backendNotInited; + } + + if (!isValidHandle(handle)) + return PlayerErrors::soundHandleNotFound; + + applyPauseState(handle, pause); + return PlayerErrors::noError; +} + +PlayerErrors Player::pauseSwitch(unsigned int handle) +{ + if (!mInited.load(std::memory_order_acquire) || + !mLifecycleRequestsAccepted.load(std::memory_order_acquire)) + { + return PlayerErrors::backendNotInited; + } + + if (!isValidHandle(handle)) + return PlayerErrors::soundHandleNotFound; + + const bool pause = !soloud.getPause(handle); + applyPauseState(handle, pause); + + return PlayerErrors::noError; +} + void Player::evaluateAudioDeviceIdle() { std::lock_guard interruptionLock(mInterruptionMutex); @@ -1410,13 +1434,21 @@ PlayerErrors Player::play( return PlayerErrors::noError; } -void Player::stop(unsigned int handle) +PlayerErrors Player::stop(unsigned int handle) { + if (!mInited.load(std::memory_order_acquire) || + !mLifecycleRequestsAccepted.load(std::memory_order_acquire)) + { + return PlayerErrors::backendNotInited; + } + + if (!isValidHandle(handle)) + return PlayerErrors::soundHandleNotFound; + soloud.stop(handle); - // After stopping, check if there are any remaining active voices. - // If no voices are active, pause the audio device to allow the OS - // to properly manage the audio session. evaluateAudioDeviceIdle(); + + return PlayerErrors::noError; } void Player::removeHandle(unsigned int handle) diff --git a/src/player.h b/src/player.h index 7ba63103..900606ed 100644 --- a/src/player.h +++ b/src/player.h @@ -201,12 +201,14 @@ class Player { /// @brief Switch pause state for an already loaded sound identified by /// [handle]. /// @param handle the sound handle - void pauseSwitch(unsigned int handle); + /// @return [noError], [backendNotInited], or [soundHandleNotFound]. + PlayerErrors pauseSwitch(unsigned int handle); /// @brief Pause or unpause already loaded sound identified by [handle]. /// @param handle the sound handle. /// @param pause whether this sound should be paused or not. - void setPause(unsigned int handle, bool pause); + /// @return [noError], [backendNotInited], or [soundHandleNotFound]. + PlayerErrors setPause(unsigned int handle, bool pause); /// @brief Schedule a deferred pause of the audio device. If no voices /// remain active after a short delay, the engine is paused. Requests are @@ -316,7 +318,8 @@ class Player { /// @brief Stop already loaded sound identified by [handle] and clear it. /// @param handle handle of the sound. - void stop(unsigned int handle); + /// @return [noError], [backendNotInited], or [soundHandleNotFound]. + PlayerErrors stop(unsigned int handle); /// @brief Remove the unique [handle] form the list of internal sounds. /// @param handle handle of the sound. @@ -752,6 +755,7 @@ class Player { std::atomic mIdleTimeoutMs; void pauseEngineScheduler(); + void applyPauseState(unsigned int handle, bool pause); PlayerErrors performAudioDeviceStart(); PlayerErrors performAudioDeviceStop(bool explicitRequest); void invalidatePendingDeviceRequest(); From 6b8bf6c62777b6e8e7e4bf77f994f9450b05c761 Mon Sep 17 00:00:00 2001 From: Colton Date: Thu, 23 Jul 2026 17:00:59 -0400 Subject: [PATCH 04/13] Queue idle policy updates asynchronously --- lib/src/bindings/bindings_player_ffi.dart | 5 ++-- src/player.cpp | 29 ++++++++++------------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/lib/src/bindings/bindings_player_ffi.dart b/lib/src/bindings/bindings_player_ffi.dart index 2d067680..2ab7b642 100644 --- a/lib/src/bindings/bindings_player_ffi.dart +++ b/lib/src/bindings/bindings_player_ffi.dart @@ -432,8 +432,9 @@ class FlutterSoLoudFfi extends FlutterSoLoud { final int timeoutMs = timeout == null ? -1 : (timeout.inMilliseconds < 0 ? 0 : timeout.inMilliseconds); - // Cheap native call (atomic store; any device start is posted to the - // background scheduler thread), so call it directly on the UI isolate. + // The native call stores the policy and posts any required lifecycle + // request. It does not start/stop the device or inspect SoLoud voice state + // inline. _setAudioDeviceIdleTimeout(timeoutMs); } diff --git a/src/player.cpp b/src/player.cpp index b27e716c..7c38df1e 100644 --- a/src/player.cpp +++ b/src/player.cpp @@ -904,26 +904,22 @@ void Player::evaluateAudioDeviceIdle() if (!mInited.load(std::memory_order_acquire) || !mLifecycleRequestsAccepted.load(std::memory_order_acquire) || mInterruptionActive.load(std::memory_order_acquire)) + { return; + } #ifdef __EMSCRIPTEN__ // The mixer invokes the inactive callback only after releasing SoLoud's // audio mutex, so this count is safe even for scheduled/natural endings. - if (soloud.getActiveVoiceCount() == 0 && mIdleTimeoutMs.load() >= 0) + if (soloud.getActiveVoiceCount() == 0 && + mIdleTimeoutMs.load(std::memory_order_acquire) >= 0) soloud.pause(); #else - { - // Serialize the count-and-request decision with start requests. If a - // play/unpause is already registered it prevents the idle request; if - // it registers immediately after this check, its newer start request - // supersedes this one. - std::lock_guard lock(mPauseMutex); - if (!mPauseThreadRunning || mStopPauseThread || - soloud.getActiveVoiceCount() != 0) - return; - mPendingDeviceRequest = DeviceLifecycleRequest::idleStop; - ++mDeviceRequestGeneration; - } - mPauseCv.notify_one(); + // Do not inspect SoLoud voice state on the FFI caller thread. + // + // The lifecycle scheduler performs the authoritative active-voice check + // after the configured timeout and immediately before stopping the + // device. + requestDeviceLifecycle(DeviceLifecycleRequest::idleStop); #endif } @@ -1048,9 +1044,8 @@ void Player::setAudioDeviceIdleTimeout(int64_t timeoutMs) } else { - // A finite timeout (including zero) takes effect immediately if the - // engine is already idle. The helper makes the count-and-request - // decision atomic with respect to a concurrent play/unpause. + // Queue an idle-stop request immediately. The scheduler applies the + // timeout and performs the authoritative active-voice check. evaluateAudioDeviceIdle(); } } From f05185f1e50d184e9ae00070cec31af237e39f97 Mon Sep 17 00:00:00 2001 From: Colton Date: Thu, 23 Jul 2026 17:31:16 -0400 Subject: [PATCH 05/13] Scope callback mutex before Player teardown --- src/bindings.cpp | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/bindings.cpp b/src/bindings.cpp index 132df9c6..3a875c06 100644 --- a/src/bindings.cpp +++ b/src/bindings.cpp @@ -25,6 +25,10 @@ std::mutex dart_callback_invocation_mutex; +// This mutex protects callback registration and invocation only. It must +// never be held while joining the lifecycle scheduler, starting or stopping +// the audio device, disposing Player, or destroying native audio sources. + namespace { constexpr int64_t kNoDartCallbackOwnerEngineId = -1; @@ -435,25 +439,27 @@ FFI_PLUGIN_EXPORT void freeListPlaybackDevices(char **devicesName, /// app /// FFI_PLUGIN_EXPORT void dispose() { - // Keep direct native callers safe too; Dart normally sets this before - // dispatching dispose() so request order is recorded without waiting for - // this worker to start. + // 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); - // Make every native-to-Dart bridge inert before Player::dispose() stops - // voices and destroys sources. Dart keeps its NativeCallables alive until - // this off-isolate native teardown has completed. - std::lock_guard callbackGuard(dart_callback_invocation_mutex); - dartVoiceEndedCallback.store(nullptr, std::memory_order_release); - dartFileLoadedCallback.store(nullptr, std::memory_order_release); - dartStateChangedCallback.store(nullptr, std::memory_order_release); - dartCallbackOwnerEngineId = kNoDartCallbackOwnerEngineId; + + // 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. + { + std::lock_guard callbackGuard( + dart_callback_invocation_mutex); + clearDartCallbackRegistrationsLocked(); + } + if (player.get() == nullptr) return; - player.get()->dispose(); + + // dart_callback_invocation_mutex must not be held beyond this point. + player->dispose(); player.reset(); - player = nullptr; player = std::make_unique(); analyzer.reset(); analyzer = std::make_unique(256); From 4f5d7f014f2b7a9b366a8f74ea9e59de2d52a496 Mon Sep 17 00:00:00 2001 From: Colton Date: Thu, 23 Jul 2026 17:35:54 -0400 Subject: [PATCH 06/13] Preserve immediate lifecycle requests against idle updates --- .../tests/audio_device_lifecycle_races.dart | 74 ++++++++++- src/player.cpp | 124 +++++++++++++++++- src/player.h | 17 ++- 3 files changed, 200 insertions(+), 15 deletions(-) diff --git a/example/tests/tests/audio_device_lifecycle_races.dart b/example/tests/tests/audio_device_lifecycle_races.dart index 82381c87..56a0d86a 100644 --- a/example/tests/tests/audio_device_lifecycle_races.dart +++ b/example/tests/tests/audio_device_lifecycle_races.dart @@ -68,6 +68,44 @@ Future testAudioDeviceLifecycleRaces() async { ); output.writeln('Failed playback leaves device stopped: OK'); + // An idle-policy update arriving immediately after an unpause must not + // replace the required device start. Repeat this while a paused voice is + // kept alive so the final idle check cannot legitimately stop the device. + final raceHandle = SoLoud.instance.play( + waveform, + paused: true, + looping: true, + volume: 0.1, + ); + for (var i = 0; i < 10; i++) { + SoLoud.instance.setPause(raceHandle, false); + SoLoud.instance.setAudioDeviceIdleTimeout(raceTimeout); + state = await _waitForDeviceState(AudioDeviceState.started); + assert( + state == AudioDeviceState.started && + SoLoud.instance.getIsValidVoiceHandle(raceHandle) && + !SoLoud.instance.getPause(raceHandle), + 'Start/idle race iteration $i lost active playback: $state', + ); + await Future.delayed( + raceTimeout + const Duration(milliseconds: 50), + ); + assert( + SoLoud.instance.getAudioDeviceState() == AudioDeviceState.started, + 'Active voice was stopped by idle policy in iteration $i.', + ); + + SoLoud.instance.setPause(raceHandle, true); + state = await _waitForDeviceState(AudioDeviceState.stopped); + assert( + state == AudioDeviceState.stopped, + 'Paused voice did not stop the device in iteration $i: $state', + ); + } + await SoLoud.instance.stop(raceHandle); + output + .writeln('Start followed by idle update preserves playback (10x): OK'); + // Explicit prewarming while idle must apply a fresh timeout afterward. SoLoud.instance.setAudioDeviceIdleTimeout(raceTimeout); await SoLoud.instance.startAudioDevice(); @@ -150,9 +188,9 @@ Future testAudioDeviceLifecycleRaces() async { } output.writeln('Concurrent start/stop serialization (5x): OK'); - // Drive interruptions through miniaudio's notification callback. Active - // voice state must survive begin/end and require recovery on interruption - // end. + // Queue a start immediately before an interruption. The interruption stop + // must win over the pending start, while the active voice remains intact + // and recovery still applies on interruption end. final handle = SoLoud.instance.play(waveform, looping: true, volume: 0.1); state = await _waitForDeviceState(AudioDeviceState.started); assert( @@ -160,6 +198,14 @@ Future testAudioDeviceLifecycleRaces() async { 'Playback did not start: $state', ); + SoLoud.instance.setPause(handle, true); + state = await _waitForDeviceState(AudioDeviceState.stopped); + assert( + state == AudioDeviceState.stopped, + 'Could not establish a stopped device before interruption race: $state', + ); + SoLoud.instance.setPause(handle, false); + final beganEvent = SoLoudController() .soLoudFFI .stateChangedEvents @@ -267,20 +313,36 @@ Future testAudioDeviceLifecycleRaces() async { ); output.writeln('Teardown during active lifecycle operation: OK'); - // Repeated fully asynchronous recreation must not leak scheduler threads. - for (var i = 0; i < 5; i++) { + // Repeated start/deinit races must not deadlock callback teardown or leak + // scheduler threads. Keep the timeout finite so each cycle also exercises + // the idle lifecycle path before the immediate start request. + for (var i = 0; i < 100; i++) { await SoLoud.instance.init(); assert( SoLoud.instance.isInitialized, 'Async cycle $i failed to initialize.', ); + + final startOperation = _captureError( + SoLoud.instance.startAudioDevice(), + ); await SoLoud.instance.deinitAsync().timeout(const Duration(seconds: 5)); + final startError = await startOperation; + assert( + startError == null || startError is SoLoudException, + 'Async cycle $i start failed unexpectedly: $startError', + ); assert( !SoLoud.instance.isInitialized, 'Async cycle $i failed to deinit.', ); + assert( + SoLoud.instance.getAudioDeviceState() == + AudioDeviceState.uninitialized, + 'Async cycle $i left the backend initialized.', + ); } - output.writeln('Repeated async init/deinit cycles (5x): OK'); + output.writeln('Repeated start/deinit races (100x): OK'); } finally { SoLoud.instance.setAudioDeviceIdleTimeout(defaultTimeout); await SoLoud.instance.deinitAsync(); diff --git a/src/player.cpp b/src/player.cpp index 7c38df1e..ad15637f 100644 --- a/src/player.cpp +++ b/src/player.cpp @@ -1169,14 +1169,76 @@ bool Player::requestDeviceLifecycle(DeviceLifecycleRequest request) (void)request; return false; #else + bool shouldNotify = false; + { std::lock_guard lock(mPauseMutex); + if (!mPauseThreadRunning || mStopPauseThread) return false; - mPendingDeviceRequest = request; - ++mDeviceRequestGeneration; + + const bool immediatePending = + mPendingDeviceRequest == DeviceLifecycleRequest::start || + mPendingDeviceRequest == + DeviceLifecycleRequest::interruptionStop; + + const bool immediateInFlight = + mImmediateDeviceRequestInFlight == DeviceLifecycleRequest::start || + mImmediateDeviceRequestInFlight == + DeviceLifecycleRequest::interruptionStop; + + switch (request) + { + case DeviceLifecycleRequest::idleStop: + if (immediatePending || immediateInFlight) + { + // Do not replace the immediate operation or advance the + // generation that validates it. Run a fresh idle timeout + // after the immediate operation completes. + mIdleStopRequestedAfterImmediateOperation = true; + return true; + } + + // A newer idle request restarts an existing idle deadline. + mPendingDeviceRequest = DeviceLifecycleRequest::idleStop; + ++mDeviceRequestGeneration; + shouldNotify = true; + break; + + case DeviceLifecycleRequest::start: + // An OS interruption stop has higher priority than startup. + if (mPendingDeviceRequest == + DeviceLifecycleRequest::interruptionStop || + mImmediateDeviceRequestInFlight == + DeviceLifecycleRequest::interruptionStop) + { + return true; + } + + // Playback startup supersedes all older idle work. + mIdleStopRequestedAfterImmediateOperation = false; + mPendingDeviceRequest = DeviceLifecycleRequest::start; + ++mDeviceRequestGeneration; + shouldNotify = true; + break; + + case DeviceLifecycleRequest::interruptionStop: + // Highest-priority operation. + mIdleStopRequestedAfterImmediateOperation = false; + mPendingDeviceRequest = + DeviceLifecycleRequest::interruptionStop; + ++mDeviceRequestGeneration; + shouldNotify = true; + break; + + case DeviceLifecycleRequest::none: + return false; + } } - mPauseCv.notify_one(); + + if (shouldNotify) + mPauseCv.notify_one(); + return true; #endif } @@ -1189,6 +1251,8 @@ void Player::startPauseEngineScheduler() return; mStopPauseThread = false; mPendingDeviceRequest = DeviceLifecycleRequest::none; + mImmediateDeviceRequestInFlight = DeviceLifecycleRequest::none; + mIdleStopRequestedAfterImmediateOperation = false; ++mDeviceRequestGeneration; mPauseThread = std::thread(&Player::pauseEngineScheduler, this); mPauseThreadRunning = true; @@ -1204,6 +1268,7 @@ void Player::stopPauseEngineScheduler() return; mStopPauseThread = true; mPendingDeviceRequest = DeviceLifecycleRequest::none; + mIdleStopRequestedAfterImmediateOperation = false; ++mDeviceRequestGeneration; } mPauseCv.notify_all(); @@ -1213,6 +1278,7 @@ void Player::stopPauseEngineScheduler() { std::lock_guard lock(mPauseMutex); mPauseThreadRunning = false; + mImmediateDeviceRequestInFlight = DeviceLifecycleRequest::none; } #endif } @@ -1233,15 +1299,18 @@ void Player::pauseEngineScheduler() const uint64_t requestGeneration = mDeviceRequestGeneration; mPendingDeviceRequest = DeviceLifecycleRequest::none; - // Starts and interruption stops are performed immediately. Any newer - // request arriving while the backend call is in progress receives a - // newer generation and is handled on the next iteration. + // Starts and interruption stops are performed immediately. Mark the + // operation in flight before releasing mPauseMutex so idle requests + // cannot replace or invalidate it while the backend call is running. if (request == DeviceLifecycleRequest::start || request == DeviceLifecycleRequest::interruptionStop) { + mImmediateDeviceRequestInFlight = request; lock.unlock(); + std::lock_guard operationLock( mDeviceLifecycleOperationMutex); + if (isDeviceRequestCurrent(requestGeneration)) { if (request == DeviceLifecycleRequest::start) @@ -1249,6 +1318,49 @@ void Player::pauseEngineScheduler() else performAudioDeviceStop(true); } + + bool shouldNotify = false; + + { + std::lock_guard pauseLock(mPauseMutex); + + mImmediateDeviceRequestInFlight = + DeviceLifecycleRequest::none; + + if (mStopPauseThread) + { + mIdleStopRequestedAfterImmediateOperation = false; + } + else if (mIdleStopRequestedAfterImmediateOperation) + { + const bool anotherImmediatePending = + mPendingDeviceRequest == + DeviceLifecycleRequest::start || + mPendingDeviceRequest == + DeviceLifecycleRequest::interruptionStop; + + if (!anotherImmediatePending) + { + mIdleStopRequestedAfterImmediateOperation = false; + + if (mPendingDeviceRequest != + DeviceLifecycleRequest::idleStop) + { + mPendingDeviceRequest = + DeviceLifecycleRequest::idleStop; + ++mDeviceRequestGeneration; + } + + shouldNotify = true; + } + // Otherwise leave the deferred flag set. It must run + // after the newer immediate operation completes. + } + } + + if (shouldNotify) + mPauseCv.notify_one(); + continue; } diff --git a/src/player.h b/src/player.h index 900606ed..80cbe9af 100644 --- a/src/player.h +++ b/src/player.h @@ -729,13 +729,24 @@ class Player { interruptionStop, idleStop, }; - // The pending request and generation are protected by mPauseMutex. Each new - // request replaces the older intent and advances the generation, allowing a + // The pending request and generation are protected by mPauseMutex. Requests + // normally replace older intent and advance the generation, allowing a // delayed idle stop to detect that it has become stale without maintaining a - // command queue. + // command queue. Immediate requests have priority over idle work. DeviceLifecycleRequest mPendingDeviceRequest = DeviceLifecycleRequest::none; uint64_t mDeviceRequestGeneration = 0; + // Protected by mPauseMutex. + // + // mImmediateDeviceRequestInFlight identifies a start/interruption operation + // that has been dequeued but has not completed. + // + // mIdleStopRequestedAfterImmediateOperation records idle policy work that + // arrived after an immediate operation was already pending or in flight. + // Such idle work must not invalidate the immediate operation. + DeviceLifecycleRequest mImmediateDeviceRequestInFlight = + DeviceLifecycleRequest::none; + bool mIdleStopRequestedAfterImmediateOperation = false; bool mStopPauseThread = false; // False before initialization is complete and from the first step of // shutdown onward. Lifecycle entry points use this to reject work that From 95fd488167ed0b82fae90a65c9282e94509b8c4c Mon Sep 17 00:00:00 2001 From: Colton Date: Thu, 23 Jul 2026 17:55:14 -0400 Subject: [PATCH 07/13] Preserve deferred starts across interruption stops --- .../tests/audio_device_lifecycle_races.dart | 36 +++++++++- src/player.cpp | 66 ++++++++++++++----- src/player.h | 6 ++ 3 files changed, 91 insertions(+), 17 deletions(-) diff --git a/example/tests/tests/audio_device_lifecycle_races.dart b/example/tests/tests/audio_device_lifecycle_races.dart index 56a0d86a..38f25990 100644 --- a/example/tests/tests/audio_device_lifecycle_races.dart +++ b/example/tests/tests/audio_device_lifecycle_races.dart @@ -242,6 +242,39 @@ Future testAudioDeviceLifecycleRaces() async { ); output.writeln('Active interruption recovery preserves voice state: OK'); + // End an interruption immediately after its begin notification. The + // recovery start may arrive while interruptionStop is still pending or in + // flight and must not be discarded. + for (var i = 0; i < 20; i++) { + final rapidBeganEvent = SoLoudController() + .soLoudFFI + .stateChangedEvents + .firstWhere( + (event) => event == PlayerStateNotification.interruptionBegan, + ) + .timeout(const Duration(seconds: 2)); + + SoLoudController().soLoudFFI.debugTriggerAudioInterruption(began: true); + await rapidBeganEvent; + + // End immediately; interruptionStop may still be pending or in flight. + SoLoudController().soLoudFFI.debugTriggerAudioInterruption(began: false); + + final rapidState = await _waitForDeviceState( + AudioDeviceState.started, + ); + assert( + rapidState == AudioDeviceState.started, + 'Rapid interruption cycle $i lost the recovery start: $rapidState', + ); + assert( + SoLoud.instance.getIsValidVoiceHandle(handle) && + !SoLoud.instance.getPause(handle), + 'Rapid interruption cycle $i changed the active voice.', + ); + } + output.writeln('Rapid interruption recovery (20x): OK'); + // Idle finite policy remains stopped after interruption recovery. SoLoud.instance.setPause(handle, true); SoLoud.instance.setAudioDeviceIdleTimeout(Duration.zero); @@ -337,8 +370,7 @@ Future testAudioDeviceLifecycleRaces() async { 'Async cycle $i failed to deinit.', ); assert( - SoLoud.instance.getAudioDeviceState() == - AudioDeviceState.uninitialized, + SoLoud.instance.getAudioDeviceState() == AudioDeviceState.uninitialized, 'Async cycle $i left the backend initialized.', ); } diff --git a/src/player.cpp b/src/player.cpp index ad15637f..15ce3e4e 100644 --- a/src/player.cpp +++ b/src/player.cpp @@ -1089,6 +1089,8 @@ void Player::invalidatePendingDeviceRequest() { std::lock_guard lock(mPauseMutex); mPendingDeviceRequest = DeviceLifecycleRequest::none; + mIdleStopRequestedAfterImmediateOperation = false; + mStartRequestedAfterInterruptionStop = false; ++mDeviceRequestGeneration; } mPauseCv.notify_one(); @@ -1212,10 +1214,15 @@ bool Player::requestDeviceLifecycle(DeviceLifecycleRequest request) mImmediateDeviceRequestInFlight == DeviceLifecycleRequest::interruptionStop) { + // The interruption stop retains priority, but the recovery + // start must run after it completes if the interruption has + // ended. + mStartRequestedAfterInterruptionStop = true; return true; } // Playback startup supersedes all older idle work. + mStartRequestedAfterInterruptionStop = false; mIdleStopRequestedAfterImmediateOperation = false; mPendingDeviceRequest = DeviceLifecycleRequest::start; ++mDeviceRequestGeneration; @@ -1224,6 +1231,9 @@ bool Player::requestDeviceLifecycle(DeviceLifecycleRequest request) case DeviceLifecycleRequest::interruptionStop: // Highest-priority operation. + // A new interruption supersedes any recovery start from an older + // interruption cycle. + mStartRequestedAfterInterruptionStop = false; mIdleStopRequestedAfterImmediateOperation = false; mPendingDeviceRequest = DeviceLifecycleRequest::interruptionStop; @@ -1253,6 +1263,7 @@ void Player::startPauseEngineScheduler() mPendingDeviceRequest = DeviceLifecycleRequest::none; mImmediateDeviceRequestInFlight = DeviceLifecycleRequest::none; mIdleStopRequestedAfterImmediateOperation = false; + mStartRequestedAfterInterruptionStop = false; ++mDeviceRequestGeneration; mPauseThread = std::thread(&Player::pauseEngineScheduler, this); mPauseThreadRunning = true; @@ -1269,6 +1280,7 @@ void Player::stopPauseEngineScheduler() mStopPauseThread = true; mPendingDeviceRequest = DeviceLifecycleRequest::none; mIdleStopRequestedAfterImmediateOperation = false; + mStartRequestedAfterInterruptionStop = false; ++mDeviceRequestGeneration; } mPauseCv.notify_all(); @@ -1329,32 +1341,56 @@ void Player::pauseEngineScheduler() if (mStopPauseThread) { + mStartRequestedAfterInterruptionStop = false; mIdleStopRequestedAfterImmediateOperation = false; } - else if (mIdleStopRequestedAfterImmediateOperation) + else { - const bool anotherImmediatePending = - mPendingDeviceRequest == - DeviceLifecycleRequest::start || + const bool interruptionStopPending = mPendingDeviceRequest == - DeviceLifecycleRequest::interruptionStop; + DeviceLifecycleRequest::interruptionStop; - if (!anotherImmediatePending) + if (request == DeviceLifecycleRequest::interruptionStop && + mStartRequestedAfterInterruptionStop && + !mInterruptionActive.load(std::memory_order_acquire) && + !interruptionStopPending) { + // The interruption has ended. Run the recovery start + // now that the higher-priority stop has completed. + mStartRequestedAfterInterruptionStop = false; + + // A start supersedes deferred idle work. mIdleStopRequestedAfterImmediateOperation = false; + mPendingDeviceRequest = + DeviceLifecycleRequest::start; + ++mDeviceRequestGeneration; + shouldNotify = true; + } + else if (mIdleStopRequestedAfterImmediateOperation) + { + const bool anotherImmediatePending = + mPendingDeviceRequest == + DeviceLifecycleRequest::start || + mPendingDeviceRequest == + DeviceLifecycleRequest::interruptionStop; - if (mPendingDeviceRequest != - DeviceLifecycleRequest::idleStop) + if (!anotherImmediatePending) { - mPendingDeviceRequest = - DeviceLifecycleRequest::idleStop; - ++mDeviceRequestGeneration; - } + mIdleStopRequestedAfterImmediateOperation = false; - shouldNotify = true; + if (mPendingDeviceRequest != + DeviceLifecycleRequest::idleStop) + { + mPendingDeviceRequest = + DeviceLifecycleRequest::idleStop; + ++mDeviceRequestGeneration; + } + + shouldNotify = true; + } + // Otherwise leave the deferred flag set. It must run + // after the newer immediate operation completes. } - // Otherwise leave the deferred flag set. It must run - // after the newer immediate operation completes. } } diff --git a/src/player.h b/src/player.h index 80cbe9af..1246c935 100644 --- a/src/player.h +++ b/src/player.h @@ -747,6 +747,12 @@ class Player { DeviceLifecycleRequest mImmediateDeviceRequestInFlight = DeviceLifecycleRequest::none; bool mIdleStopRequestedAfterImmediateOperation = false; + // Protected by mPauseMutex. + // + // Records that playback recovery requested a start while an interruption + // stop was pending or in flight. The start must run after the interruption + // stop completes, provided the interruption has ended. + bool mStartRequestedAfterInterruptionStop = false; bool mStopPauseThread = false; // False before initialization is complete and from the first step of // shutdown onward. Lifecycle entry points use this to reject work that From 80af6b1b7ca2e71df7383b2c08af03506346eb3c Mon Sep 17 00:00:00 2001 From: Colton Date: Thu, 23 Jul 2026 18:20:42 -0400 Subject: [PATCH 08/13] Preserve newer idle work after deferred recovery --- .../tests/audio_device_lifecycle_races.dart | 20 ++++++++++++++++--- src/player.cpp | 4 +--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/example/tests/tests/audio_device_lifecycle_races.dart b/example/tests/tests/audio_device_lifecycle_races.dart index 38f25990..4593d215 100644 --- a/example/tests/tests/audio_device_lifecycle_races.dart +++ b/example/tests/tests/audio_device_lifecycle_races.dart @@ -253,6 +253,20 @@ Future testAudioDeviceLifecycleRaces() async { (event) => event == PlayerStateNotification.interruptionBegan, ) .timeout(const Duration(seconds: 2)); + final stoppedEvent = SoLoudController() + .soLoudFFI + .stateChangedEvents + .firstWhere( + (event) => event == PlayerStateNotification.stopped, + ) + .timeout(const Duration(seconds: 2)); + final restartedEvent = SoLoudController() + .soLoudFFI + .stateChangedEvents + .firstWhere( + (event) => event == PlayerStateNotification.started, + ) + .timeout(const Duration(seconds: 2)); SoLoudController().soLoudFFI.debugTriggerAudioInterruption(began: true); await rapidBeganEvent; @@ -260,9 +274,9 @@ Future testAudioDeviceLifecycleRaces() async { // End immediately; interruptionStop may still be pending or in flight. SoLoudController().soLoudFFI.debugTriggerAudioInterruption(began: false); - final rapidState = await _waitForDeviceState( - AudioDeviceState.started, - ); + await stoppedEvent; + await restartedEvent; + final rapidState = SoLoud.instance.getAudioDeviceState(); assert( rapidState == AudioDeviceState.started, 'Rapid interruption cycle $i lost the recovery start: $rapidState', diff --git a/src/player.cpp b/src/player.cpp index 15ce3e4e..a6f03927 100644 --- a/src/player.cpp +++ b/src/player.cpp @@ -1217,6 +1217,7 @@ bool Player::requestDeviceLifecycle(DeviceLifecycleRequest request) // The interruption stop retains priority, but the recovery // start must run after it completes if the interruption has // ended. + mIdleStopRequestedAfterImmediateOperation = false; mStartRequestedAfterInterruptionStop = true; return true; } @@ -1358,9 +1359,6 @@ void Player::pauseEngineScheduler() // The interruption has ended. Run the recovery start // now that the higher-priority stop has completed. mStartRequestedAfterInterruptionStop = false; - - // A start supersedes deferred idle work. - mIdleStopRequestedAfterImmediateOperation = false; mPendingDeviceRequest = DeviceLifecycleRequest::start; ++mDeviceRequestGeneration; From b8f40e86a30cb58f3ed19da268bf5c5f4dd4141d Mon Sep 17 00:00:00 2001 From: Colton Date: Thu, 23 Jul 2026 20:13:16 -0400 Subject: [PATCH 09/13] Reset idle timeout before interruption race test --- .../tests/audio_device_lifecycle_races.dart | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/example/tests/tests/audio_device_lifecycle_races.dart b/example/tests/tests/audio_device_lifecycle_races.dart index 4593d215..96778286 100644 --- a/example/tests/tests/audio_device_lifecycle_races.dart +++ b/example/tests/tests/audio_device_lifecycle_races.dart @@ -82,9 +82,7 @@ Future testAudioDeviceLifecycleRaces() async { SoLoud.instance.setAudioDeviceIdleTimeout(raceTimeout); state = await _waitForDeviceState(AudioDeviceState.started); assert( - state == AudioDeviceState.started && - SoLoud.instance.getIsValidVoiceHandle(raceHandle) && - !SoLoud.instance.getPause(raceHandle), + state == AudioDeviceState.started && SoLoud.instance.getIsValidVoiceHandle(raceHandle) && !SoLoud.instance.getPause(raceHandle), 'Start/idle race iteration $i lost active playback: $state', ); await Future.delayed( @@ -103,8 +101,7 @@ Future testAudioDeviceLifecycleRaces() async { ); } await SoLoud.instance.stop(raceHandle); - output - .writeln('Start followed by idle update preserves playback (10x): OK'); + output.writeln('Start followed by idle update preserves playback (10x): OK'); // Explicit prewarming while idle must apply a fresh timeout afterward. SoLoud.instance.setAudioDeviceIdleTimeout(raceTimeout); @@ -142,10 +139,8 @@ Future testAudioDeviceLifecycleRaces() async { // start Future. The later stop must determine the final state. SoLoud.instance.setAudioDeviceIdleTimeout(const Duration(seconds: 5)); final orderedStart = SoLoud.instance.startAudioDevice(); - final startObservationDeadline = - DateTime.now().add(const Duration(seconds: 1)); - while (SoLoud.instance.getAudioDeviceState() == AudioDeviceState.stopped && - DateTime.now().isBefore(startObservationDeadline)) { + final startObservationDeadline = DateTime.now().add(const Duration(seconds: 1)); + while (SoLoud.instance.getAudioDeviceState() == AudioDeviceState.stopped && DateTime.now().isBefore(startObservationDeadline)) { await Future.delayed(Duration.zero); } assert( @@ -174,8 +169,7 @@ Future testAudioDeviceLifecycleRaces() async { ); final settledState = SoLoud.instance.getAudioDeviceState(); assert( - settledState == AudioDeviceState.started || - settledState == AudioDeviceState.stopped, + settledState == AudioDeviceState.started || settledState == AudioDeviceState.stopped, 'Concurrent start/stop left transitional state: $settledState', ); await SoLoud.instance.startAudioDevice(); @@ -188,10 +182,17 @@ Future testAudioDeviceLifecycleRaces() async { } output.writeln('Concurrent start/stop serialization (5x): OK'); - // Queue a start immediately before an interruption. The interruption stop - // must win over the pending start, while the active voice remains intact - // and recovery still applies on interruption end. - final handle = SoLoud.instance.play(waveform, looping: true, volume: 0.1); +// Queue a start immediately before an interruption. The interruption stop +// must win over the pending start, while the active voice remains intact +// and recovery still applies on interruption end. + SoLoud.instance.setAudioDeviceIdleTimeout(Duration.zero); + + final handle = SoLoud.instance.play( + waveform, + looping: true, + volume: 0.1, + ); + state = await _waitForDeviceState(AudioDeviceState.started); assert( state == AudioDeviceState.started, @@ -204,8 +205,8 @@ Future testAudioDeviceLifecycleRaces() async { state == AudioDeviceState.stopped, 'Could not establish a stopped device before interruption race: $state', ); - SoLoud.instance.setPause(handle, false); + SoLoud.instance.setPause(handle, false); final beganEvent = SoLoudController() .soLoudFFI .stateChangedEvents @@ -221,8 +222,7 @@ Future testAudioDeviceLifecycleRaces() async { 'Interruption did not stop device.', ); assert( - SoLoud.instance.getIsValidVoiceHandle(handle) && - !SoLoud.instance.getPause(handle), + SoLoud.instance.getIsValidVoiceHandle(handle) && !SoLoud.instance.getPause(handle), 'Interruption begin mutated or invalidated the active voice.', ); @@ -282,8 +282,7 @@ Future testAudioDeviceLifecycleRaces() async { 'Rapid interruption cycle $i lost the recovery start: $rapidState', ); assert( - SoLoud.instance.getIsValidVoiceHandle(handle) && - !SoLoud.instance.getPause(handle), + SoLoud.instance.getIsValidVoiceHandle(handle) && !SoLoud.instance.getPause(handle), 'Rapid interruption cycle $i changed the active voice.', ); } @@ -346,8 +345,7 @@ Future testAudioDeviceLifecycleRaces() async { await _waitForDeviceState(AudioDeviceState.stopped); SoLoud.instance.setAudioDeviceIdleTimeout(const Duration(seconds: 5)); final activeOperation = _captureError(SoLoud.instance.startAudioDevice()); - final activeTeardown = - SoLoud.instance.deinitAsync().timeout(const Duration(seconds: 5)); + final activeTeardown = SoLoud.instance.deinitAsync().timeout(const Duration(seconds: 5)); final operationError = await activeOperation; await activeTeardown; assert( From d2c86b8a0bd788e8efb835358aa02daaa7680e01 Mon Sep 17 00:00:00 2001 From: Colton Date: Thu, 23 Jul 2026 22:09:40 -0400 Subject: [PATCH 10/13] Add Java Android plugin lifecycle bridge --- android/build.gradle | 4 +- .../flutter_soloud/FlutterSoloudPlugin.java | 37 +++++++++++++++++++ .../flutter_soloud/FlutterSoloudPlugin.kt | 33 ----------------- src/bindings.cpp | 6 ++- 4 files changed, 43 insertions(+), 37 deletions(-) create mode 100644 android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java delete mode 100644 android/src/main/kotlin/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.kt diff --git a/android/build.gradle b/android/build.gradle index 9982aaab..81bfa853 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -97,7 +97,7 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } } diff --git a/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java b/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java new file mode 100644 index 00000000..9484b3df --- /dev/null +++ b/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java @@ -0,0 +1,37 @@ +package flutter.soloud.flutter_soloud; + +import androidx.annotation.NonNull; +import io.flutter.embedding.engine.plugins.FlutterPlugin; + +public final class FlutterSoloudPlugin implements FlutterPlugin { + static { + System.loadLibrary("flutter_soloud_plugin"); + } + + private static native boolean + nativeClearDartCallbackRegistrationsForEngine(long engineId); + + private Long engineId; + + @SuppressWarnings("deprecation") + @Override + public void onAttachedToEngine( + @NonNull FlutterPluginBinding binding + ) { + engineId = binding.getFlutterEngine().getEngineId(); + } + + @Override + public void onDetachedFromEngine( + @NonNull FlutterPluginBinding binding + ) { + final Long detachedEngineId = engineId; + engineId = null; + + if (detachedEngineId != null) { + nativeClearDartCallbackRegistrationsForEngine( + detachedEngineId + ); + } + } +} diff --git a/android/src/main/kotlin/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.kt b/android/src/main/kotlin/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.kt deleted file mode 100644 index a0f3e105..00000000 --- a/android/src/main/kotlin/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.kt +++ /dev/null @@ -1,33 +0,0 @@ -package flutter.soloud.flutter_soloud - -import io.flutter.embedding.engine.plugins.FlutterPlugin - -class FlutterSoloudPlugin : FlutterPlugin { - companion object { - init { - System.loadLibrary("flutter_soloud_plugin") - } - - @JvmStatic - private external fun nativeClearDartCallbackRegistrationsForEngine( - engineId: Long, - ): Boolean - } - - private var engineId: Long? = null - - @Suppress("DEPRECATION") - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - // Dart continues using FFI; no platform channel is required. - engineId = binding.flutterEngine.engineId - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - val detachedEngineId = engineId - engineId = null - - if (detachedEngineId != null) { - nativeClearDartCallbackRegistrationsForEngine(detachedEngineId) - } - } -} diff --git a/src/bindings.cpp b/src/bindings.cpp index 3a875c06..16cc4e91 100644 --- a/src/bindings.cpp +++ b/src/bindings.cpp @@ -15,6 +15,10 @@ #include #endif +#if defined(__ANDROID__) +#include +#endif + #include #include #include @@ -466,8 +470,6 @@ FFI_PLUGIN_EXPORT void dispose() { } #if defined(__ANDROID__) -#include - extern "C" JNIEXPORT jboolean JNICALL Java_flutter_soloud_flutter_1soloud_FlutterSoloudPlugin_nativeClearDartCallbackRegistrationsForEngine( JNIEnv *, jclass, jlong engine_id) { From 13f9141f42597b0ab08ca256e3f8a798c879b05d Mon Sep 17 00:00:00 2001 From: Colton Date: Thu, 23 Jul 2026 22:13:58 -0400 Subject: [PATCH 11/13] Keep post-deinit callback cleanup Dart-local --- lib/src/bindings/bindings_player_ffi.dart | 3 --- test/dispose_native_callables_test.dart | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 test/dispose_native_callables_test.dart diff --git a/lib/src/bindings/bindings_player_ffi.dart b/lib/src/bindings/bindings_player_ffi.dart index 2ab7b642..e6005fed 100644 --- a/lib/src/bindings/bindings_player_ffi.dart +++ b/lib/src/bindings/bindings_player_ffi.dart @@ -246,9 +246,6 @@ class FlutterSoLoudFfi extends FlutterSoLoud { @override void disposeNativeCallables() { - // Native clearing is a synchronous barrier. It returns only after any - // callback trampoline invocation already in progress has completed. - clearDartCallbackRegistrations(); _disposeAllBufferStreamCallbacks(); nativeVoiceEndedCallable?.close(); nativeVoiceEndedCallable = null; diff --git a/test/dispose_native_callables_test.dart b/test/dispose_native_callables_test.dart new file mode 100644 index 00000000..9423eec7 --- /dev/null +++ b/test/dispose_native_callables_test.dart @@ -0,0 +1,16 @@ +import 'dart:ffi' as ffi; + +import 'package:flutter_soloud/src/bindings/bindings_player_ffi.dart'; +import 'package:test/test.dart'; + +void main() { + test('disposeNativeCallables does not clear native registrations', () { + final bindings = FlutterSoLoudFfi.fromLookup(( + String symbol, + ) { + throw StateError('Native lookup should not be called for $symbol.'); + }); + + expect(bindings.disposeNativeCallables, returnsNormally); + }); +} From aefe5b8577375269c697529f3aa581e3cfdf31e2 Mon Sep 17 00:00:00 2001 From: Colton Date: Thu, 23 Jul 2026 22:42:43 -0400 Subject: [PATCH 12/13] only close callables after native teardown succeeds --- lib/src/soloud.dart | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/lib/src/soloud.dart b/lib/src/soloud.dart index 4741dd8d..e7878035 100644 --- a/lib/src/soloud.dart +++ b/lib/src/soloud.dart @@ -630,11 +630,9 @@ interface class SoLoud { void deinit() { _log.finest('deinit() called'); _predeinit(); - try { - _controller.soLoudFFI.deinit(); - } finally { - _postdeinit(); - } + + _controller.soLoudFFI.deinit(); + _postdeinit(); } /// Like [deinit], but runs the blocking native teardown (audio device @@ -664,11 +662,8 @@ interface class SoLoud { } Future _deinitNativeAsync() async { - try { - await _controller.soLoudFFI.deinitAsync(); - } finally { - _postdeinit(); - } + await _controller.soLoudFFI.deinitAsync(); + _postdeinit(); } /// Marks the Dart side unavailable before native teardown begins. @@ -1786,10 +1781,7 @@ interface class SoLoud { throw const SoLoudNotInitializedException(); } - final error = _controller.soLoudFFI.setPause( - handle, - pause ? 1 : 0, - ); + final error = _controller.soLoudFFI.setPause(handle, pause ? 1 : 0); if (error != PlayerErrors.noError) { _logPlayerError(error, from: 'setPause()'); From f1a0c877f947eb9085566e0fe4db1d768c8efc91 Mon Sep 17 00:00:00 2001 From: Colton Date: Thu, 23 Jul 2026 22:45:46 -0400 Subject: [PATCH 13/13] formatting --- .../tests/audio_device_idle_timeout.dart | 54 ++++++++++++------- .../tests/audio_device_lifecycle_races.dart | 25 ++++++--- lib/src/bindings/bindings_player_ffi.dart | 13 ++--- 3 files changed, 60 insertions(+), 32 deletions(-) diff --git a/example/tests/tests/audio_device_idle_timeout.dart b/example/tests/tests/audio_device_idle_timeout.dart index 520ee38f..c717c3c5 100644 --- a/example/tests/tests/audio_device_idle_timeout.dart +++ b/example/tests/tests/audio_device_idle_timeout.dart @@ -33,7 +33,8 @@ Future testAudioDeviceIdleTimeout() async { // Allow a short grace period for async stop transitions. final deadline = DateTime.now().add(const Duration(milliseconds: 1000)); - while (stoppedState != AudioDeviceState.stopped && DateTime.now().isBefore(deadline)) { + while (stoppedState != AudioDeviceState.stopped && + DateTime.now().isBefore(deadline)) { await Future.delayed(const Duration(milliseconds: 25)); stoppedState = SoLoud.instance.getAudioDeviceState(); } @@ -69,7 +70,8 @@ Future testAudioDeviceIdleTimeout() async { 'After creating a paused handle, expected AudioDeviceState.stopped ' 'but got $stateAfterPausedHandle.', ); - strBuf.writeln('State after paused handle creation: $stateAfterPausedHandle'); + strBuf + .writeln('State after paused handle creation: $stateAfterPausedHandle'); // 4) Play the sound handle for a few seconds. SoLoud.instance.setPause(handle, false); @@ -131,8 +133,10 @@ Future testAudioDeviceIdleTimeout() async { await Future.delayed(idleTimeout); var stateAfterPauseIdle = SoLoud.instance.getAudioDeviceState(); - final pauseIdleDeadline = DateTime.now().add(const Duration(milliseconds: 1000)); - while (stateAfterPauseIdle != AudioDeviceState.stopped && DateTime.now().isBefore(pauseIdleDeadline)) { + final pauseIdleDeadline = + DateTime.now().add(const Duration(milliseconds: 1000)); + while (stateAfterPauseIdle != AudioDeviceState.stopped && + DateTime.now().isBefore(pauseIdleDeadline)) { await Future.delayed(const Duration(milliseconds: 25)); stateAfterPauseIdle = SoLoud.instance.getAudioDeviceState(); } @@ -147,8 +151,10 @@ Future testAudioDeviceIdleTimeout() async { // 8) Resume and verify started before schedulePause. SoLoud.instance.setPause(handle, false); var stateBeforeSchedulePause = SoLoud.instance.getAudioDeviceState(); - final startedBeforePauseDeadline = DateTime.now().add(const Duration(milliseconds: 1000)); - while (stateBeforeSchedulePause != AudioDeviceState.started && DateTime.now().isBefore(startedBeforePauseDeadline)) { + final startedBeforePauseDeadline = + DateTime.now().add(const Duration(milliseconds: 1000)); + while (stateBeforeSchedulePause != AudioDeviceState.started && + DateTime.now().isBefore(startedBeforePauseDeadline)) { await Future.delayed(const Duration(milliseconds: 25)); stateBeforeSchedulePause = SoLoud.instance.getAudioDeviceState(); } @@ -167,13 +173,16 @@ Future testAudioDeviceIdleTimeout() async { pausedAfterSchedulePause, 'After schedulePause(1000ms), expected handle to be paused.', ); - strBuf.writeln('Handle paused after schedulePause: $pausedAfterSchedulePause'); + strBuf.writeln( + 'Handle paused after schedulePause: $pausedAfterSchedulePause'); // 10) Wait idle timeout again and verify stopped. await Future.delayed(idleTimeout); var stateAfterSchedulePauseIdle = SoLoud.instance.getAudioDeviceState(); - final stoppedAfterSchedulePauseDeadline = DateTime.now().add(const Duration(milliseconds: 1000)); - while (stateAfterSchedulePauseIdle != AudioDeviceState.stopped && DateTime.now().isBefore(stoppedAfterSchedulePauseDeadline)) { + final stoppedAfterSchedulePauseDeadline = + DateTime.now().add(const Duration(milliseconds: 1000)); + while (stateAfterSchedulePauseIdle != AudioDeviceState.stopped && + DateTime.now().isBefore(stoppedAfterSchedulePauseDeadline)) { await Future.delayed(const Duration(milliseconds: 25)); stateAfterSchedulePauseIdle = SoLoud.instance.getAudioDeviceState(); } @@ -189,8 +198,10 @@ Future testAudioDeviceIdleTimeout() async { // 11) Resume and verify started before scheduleStop. SoLoud.instance.setPause(handle, false); var stateBeforeScheduleStop = SoLoud.instance.getAudioDeviceState(); - final startedBeforeStopDeadline = DateTime.now().add(const Duration(milliseconds: 1000)); - while (stateBeforeScheduleStop != AudioDeviceState.started && DateTime.now().isBefore(startedBeforeStopDeadline)) { + final startedBeforeStopDeadline = + DateTime.now().add(const Duration(milliseconds: 1000)); + while (stateBeforeScheduleStop != AudioDeviceState.started && + DateTime.now().isBefore(startedBeforeStopDeadline)) { await Future.delayed(const Duration(milliseconds: 25)); stateBeforeScheduleStop = SoLoud.instance.getAudioDeviceState(); } @@ -204,11 +215,15 @@ Future testAudioDeviceIdleTimeout() async { // 12) Schedule stop in 1000 ms and verify handle invalidated. SoLoud.instance.scheduleStop(handle, const Duration(milliseconds: 1000)); await Future.delayed(const Duration(milliseconds: 1100)); - var isHandleValidAfterScheduleStop = SoLoud.instance.getIsValidVoiceHandle(handle); - final invalidHandleDeadline = DateTime.now().add(const Duration(milliseconds: 1000)); - while (isHandleValidAfterScheduleStop && DateTime.now().isBefore(invalidHandleDeadline)) { + var isHandleValidAfterScheduleStop = + SoLoud.instance.getIsValidVoiceHandle(handle); + final invalidHandleDeadline = + DateTime.now().add(const Duration(milliseconds: 1000)); + while (isHandleValidAfterScheduleStop && + DateTime.now().isBefore(invalidHandleDeadline)) { await Future.delayed(const Duration(milliseconds: 25)); - isHandleValidAfterScheduleStop = SoLoud.instance.getIsValidVoiceHandle(handle); + isHandleValidAfterScheduleStop = + SoLoud.instance.getIsValidVoiceHandle(handle); } assert( !isHandleValidAfterScheduleStop, @@ -221,8 +236,10 @@ Future testAudioDeviceIdleTimeout() async { // 13) Wait idle timeout again and verify stopped (scheduleStop last). await Future.delayed(idleTimeout); var stateAfterScheduleStopIdle = SoLoud.instance.getAudioDeviceState(); - final stoppedAfterScheduleStopDeadline = DateTime.now().add(const Duration(milliseconds: 1000)); - while (stateAfterScheduleStopIdle != AudioDeviceState.stopped && DateTime.now().isBefore(stoppedAfterScheduleStopDeadline)) { + final stoppedAfterScheduleStopDeadline = + DateTime.now().add(const Duration(milliseconds: 1000)); + while (stateAfterScheduleStopIdle != AudioDeviceState.stopped && + DateTime.now().isBefore(stoppedAfterScheduleStopDeadline)) { await Future.delayed(const Duration(milliseconds: 25)); stateAfterScheduleStopIdle = SoLoud.instance.getAudioDeviceState(); } @@ -264,7 +281,8 @@ Future testAudioDeviceIdleTimeout() async { 'After starting explosion playback, expected AudioDeviceState.started ' 'within 1000 ms but got $stateDuringExplosionStart.', ); - strBuf.writeln('State during explosion playback: $stateDuringExplosionStart'); + strBuf + .writeln('State during explosion playback: $stateDuringExplosionStart'); // 16) Wait for full playback duration. await Future.delayed(explosionDuration); diff --git a/example/tests/tests/audio_device_lifecycle_races.dart b/example/tests/tests/audio_device_lifecycle_races.dart index 96778286..64df3a3d 100644 --- a/example/tests/tests/audio_device_lifecycle_races.dart +++ b/example/tests/tests/audio_device_lifecycle_races.dart @@ -82,7 +82,9 @@ Future testAudioDeviceLifecycleRaces() async { SoLoud.instance.setAudioDeviceIdleTimeout(raceTimeout); state = await _waitForDeviceState(AudioDeviceState.started); assert( - state == AudioDeviceState.started && SoLoud.instance.getIsValidVoiceHandle(raceHandle) && !SoLoud.instance.getPause(raceHandle), + state == AudioDeviceState.started && + SoLoud.instance.getIsValidVoiceHandle(raceHandle) && + !SoLoud.instance.getPause(raceHandle), 'Start/idle race iteration $i lost active playback: $state', ); await Future.delayed( @@ -101,7 +103,8 @@ Future testAudioDeviceLifecycleRaces() async { ); } await SoLoud.instance.stop(raceHandle); - output.writeln('Start followed by idle update preserves playback (10x): OK'); + output + .writeln('Start followed by idle update preserves playback (10x): OK'); // Explicit prewarming while idle must apply a fresh timeout afterward. SoLoud.instance.setAudioDeviceIdleTimeout(raceTimeout); @@ -139,8 +142,10 @@ Future testAudioDeviceLifecycleRaces() async { // start Future. The later stop must determine the final state. SoLoud.instance.setAudioDeviceIdleTimeout(const Duration(seconds: 5)); final orderedStart = SoLoud.instance.startAudioDevice(); - final startObservationDeadline = DateTime.now().add(const Duration(seconds: 1)); - while (SoLoud.instance.getAudioDeviceState() == AudioDeviceState.stopped && DateTime.now().isBefore(startObservationDeadline)) { + final startObservationDeadline = + DateTime.now().add(const Duration(seconds: 1)); + while (SoLoud.instance.getAudioDeviceState() == AudioDeviceState.stopped && + DateTime.now().isBefore(startObservationDeadline)) { await Future.delayed(Duration.zero); } assert( @@ -169,7 +174,8 @@ Future testAudioDeviceLifecycleRaces() async { ); final settledState = SoLoud.instance.getAudioDeviceState(); assert( - settledState == AudioDeviceState.started || settledState == AudioDeviceState.stopped, + settledState == AudioDeviceState.started || + settledState == AudioDeviceState.stopped, 'Concurrent start/stop left transitional state: $settledState', ); await SoLoud.instance.startAudioDevice(); @@ -222,7 +228,8 @@ Future testAudioDeviceLifecycleRaces() async { 'Interruption did not stop device.', ); assert( - SoLoud.instance.getIsValidVoiceHandle(handle) && !SoLoud.instance.getPause(handle), + SoLoud.instance.getIsValidVoiceHandle(handle) && + !SoLoud.instance.getPause(handle), 'Interruption begin mutated or invalidated the active voice.', ); @@ -282,7 +289,8 @@ Future testAudioDeviceLifecycleRaces() async { 'Rapid interruption cycle $i lost the recovery start: $rapidState', ); assert( - SoLoud.instance.getIsValidVoiceHandle(handle) && !SoLoud.instance.getPause(handle), + SoLoud.instance.getIsValidVoiceHandle(handle) && + !SoLoud.instance.getPause(handle), 'Rapid interruption cycle $i changed the active voice.', ); } @@ -345,7 +353,8 @@ Future testAudioDeviceLifecycleRaces() async { await _waitForDeviceState(AudioDeviceState.stopped); SoLoud.instance.setAudioDeviceIdleTimeout(const Duration(seconds: 5)); final activeOperation = _captureError(SoLoud.instance.startAudioDevice()); - final activeTeardown = SoLoud.instance.deinitAsync().timeout(const Duration(seconds: 5)); + final activeTeardown = + SoLoud.instance.deinitAsync().timeout(const Duration(seconds: 5)); final operationError = await activeOperation; await activeTeardown; assert( diff --git a/lib/src/bindings/bindings_player_ffi.dart b/lib/src/bindings/bindings_player_ffi.dart index e6005fed..20117046 100644 --- a/lib/src/bindings/bindings_player_ffi.dart +++ b/lib/src/bindings/bindings_player_ffi.dart @@ -31,9 +31,10 @@ import 'package:meta/meta.dart'; /// Only [address] (a sendable int) crosses the isolate boundary; the pointer is /// reconstructed here and the same process-global device is operated on. int _invokeDeviceLifecycle(int address) { - final fn = ffi.Pointer> - .fromAddress(address) - .asFunction(); + final fn = + ffi.Pointer>.fromAddress( + address, + ).asFunction(); return fn(); } @@ -96,9 +97,9 @@ int _invokeInitEngine( /// teardown (device uninit) then executes off the UI isolate instead of /// stalling it. Only [address] (a sendable int) crosses the isolate boundary. void _invokeVoidNative(int address) { - ffi.Pointer> - .fromAddress(address) - .asFunction()(); + ffi.Pointer>.fromAddress( + address, + ).asFunction()(); } typedef DartVoiceEndedCallbackT =