From 08d7f79cad41ed398a1ccacefe3d2118c1bc4cdb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 04:28:35 +0000 Subject: [PATCH] miniaudio: bound the OpenSL ES buffer-queue drain ma_device_drain__opensl() spins in an unbounded for(;;)/ma_sleep(10) loop until the buffer queue reports empty, with no timeout and no iteration cap. If the OpenSL callback thread stops servicing the queue -- audioserver hiccup, route change, device disconnect -- the count never reaches zero and ma_device_stop() never returns (#333). Since the audio device is stopped on every idle timeout to release the audioserver AudioMix partial wakelock, that is an unrecoverable hang on a routine path. miniaudio itself disabled the equivalent stop inside ma_device_uninit() for the same reason; see its "can result in a deadlock" comment there. The queue can only legitimately hold `periods` buffers of `periodSizeInFrames`, so the wait is capped at twice that duration, clamped to [200ms, 1000ms]. Bailing out is safe: both callers in ma_device_stop__opensl() immediately follow the drain with SetPlayState(SL_PLAYSTATE_STOPPED) and Clear() on the same queue, so the worst case is discarding a few milliseconds of tail audio that Clear() was going to discard anyway. This is a local patch to vendored miniaudio 0.11.25, marked with a greppable banner. It should be carried across miniaudio updates, and is worth reporting upstream: the loop is unbounded for every OpenSL user. Verified without an NDK (the backend is __ANDROID__-only) by exercising the added arithmetic against the real ma_device struct (4096x3@48k -> 512ms, 512x3@48k -> 200ms floor, 8192x4@44k -> 1000ms ceiling, 0x0@0 -> no divide by zero, UINT32_MAX x8 -> no 32-bit overflow), compiling the patched function verbatim against OpenSL stubs under C99 and C89 with -Wall -Wextra, and running a stalled-queue harness: the unpatched function loops forever (killed at 5s), the patched one returns after ~520ms matching the bound. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + src/soloud/src/backend/miniaudio/miniaudio.h | 91 ++++++++++++++++---- 2 files changed, 76 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9324764..f0406efe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 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: bound the OpenSL ES buffer-queue drain on Android. A stalled OpenSL callback queue could otherwise block `ma_device_stop()` indefinitely, including the automatic idle-stop path. The drain now has a 200–1000 ms polling budget based on the configured queue duration before stop and queue clearing continue #333 - 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 - add `stopAudioDevice()` / `startAudioDevice()` to control the audio output device without deinitializing the engine (loaded sounds and voice state are preserved); `stopAudioDevice()` is an idle-only no-op while an unpaused voice is active unless `force: true` is passed, while `startAudioDevice()` temporarily starts or prewarms the output and remains subject to the configured idle timeout; blocking native device operations run off the UI thread diff --git a/src/soloud/src/backend/miniaudio/miniaudio.h b/src/soloud/src/backend/miniaudio/miniaudio.h index c6d493ee..7296b39c 100644 --- a/src/soloud/src/backend/miniaudio/miniaudio.h +++ b/src/soloud/src/backend/miniaudio/miniaudio.h @@ -41234,7 +41234,7 @@ static ma_result ma_device_drain__opensl(ma_device* pDevice, ma_device_type devi MA_ASSERT(deviceType == ma_device_type_capture || deviceType == ma_device_type_playback); - if (pDevice->type == ma_device_type_capture) { + if (deviceType == ma_device_type_capture) { pBufferQueue = (SLAndroidSimpleBufferQueueItf)pDevice->opensl.pBufferQueueCapture; pDevice->opensl.isDrainingCapture = MA_TRUE; } else { @@ -41242,22 +41242,73 @@ static ma_result ma_device_drain__opensl(ma_device* pDevice, ma_device_type devi pDevice->opensl.isDrainingPlayback = MA_TRUE; } - for (;;) { - SLAndroidSimpleBufferQueueState state; + /* + ###### flutter_soloud local patch (miniaudio 0.11.25) ###### + + Upstream loops here until the buffer queue reports empty, with no timeout + and no iteration cap. If the OpenSL callback thread stops servicing the + queue -- audioserver hiccup, route change, device disconnect -- it can + never reach zero and ma_device_stop() never returns. flutter_soloud invokes + ma_device_stop() routinely for idle stopping, so it must not be able to + hang. + + Bound the wait instead. The queue can only legitimately hold `periods` + buffers of `periodSizeInFrames`, so allow twice that duration (clamped to a + sane 200--1000 ms floor/ceiling) and then give up. GetState() failure also + ends the drain attempt. The caller proceeds to stop and clear the queue. + The draining flag intentionally remains set until stop and clearing finish, + preventing late callbacks from enqueueing more data. + + Keep this patch when updating miniaudio.h -- grep for "flutter_soloud local + patch". + */ + { + ma_uint32 periodSizeInFrames; + ma_uint32 periods; + ma_uint32 sampleRate; + ma_uint32 maxWaitMs; + ma_uint32 waitedMs = 0; - MA_OPENSL_BUFFERQUEUE(pBufferQueue)->GetState(pBufferQueue, &state); - if (state.count == 0) { - break; + if (deviceType == ma_device_type_capture) { + periodSizeInFrames = pDevice->capture.internalPeriodSizeInFrames; + periods = pDevice->capture.internalPeriods; + sampleRate = pDevice->capture.internalSampleRate; + } else { + periodSizeInFrames = pDevice->playback.internalPeriodSizeInFrames; + periods = pDevice->playback.internalPeriods; + sampleRate = pDevice->playback.internalSampleRate; } - ma_sleep(10); - } + maxWaitMs = 0; + if (sampleRate > 0) { + maxWaitMs = (ma_uint32)(((ma_uint64)periodSizeInFrames * periods * 2000) / sampleRate); + } + if (maxWaitMs < 200) { maxWaitMs = 200; } /* Floor: tolerate a slow but healthy drain. */ + if (maxWaitMs > 1000) { maxWaitMs = 1000; } /* Ceiling: never hang the caller. */ - if (pDevice->type == ma_device_type_capture) { - pDevice->opensl.isDrainingCapture = MA_FALSE; - } else { - pDevice->opensl.isDrainingPlayback = MA_FALSE; + for (;;) { + SLresult resultSL; + SLAndroidSimpleBufferQueueState state; + + resultSL = MA_OPENSL_BUFFERQUEUE(pBufferQueue)->GetState(pBufferQueue, &state); + if (resultSL != SL_RESULT_SUCCESS) { + ma_log_post(ma_device_get_log(pDevice), MA_LOG_LEVEL_WARNING, "[OpenSL] Failed to query buffer queue state while draining."); + break; + } + + if (state.count == 0) { + break; + } + + if (waitedMs >= maxWaitMs) { + break; /* Stalled queue. SetPlayState()/Clear() below still run. */ + } + + ma_sleep(10); + waitedMs += 10; + } } + /* ###### end flutter_soloud local patch ###### */ return MA_SUCCESS; } @@ -41277,24 +41328,32 @@ static ma_result ma_device_stop__opensl(ma_device* pDevice) ma_device_drain__opensl(pDevice, ma_device_type_capture); resultSL = MA_OPENSL_RECORD(pDevice->opensl.pAudioRecorder)->SetRecordState((SLRecordItf)pDevice->opensl.pAudioRecorder, SL_RECORDSTATE_STOPPED); + if (resultSL == SL_RESULT_SUCCESS) { + MA_OPENSL_BUFFERQUEUE(pDevice->opensl.pBufferQueueCapture)->Clear((SLAndroidSimpleBufferQueueItf)pDevice->opensl.pBufferQueueCapture); + } + + pDevice->opensl.isDrainingCapture = MA_FALSE; + if (resultSL != SL_RESULT_SUCCESS) { ma_log_post(ma_device_get_log(pDevice), MA_LOG_LEVEL_ERROR, "[OpenSL] Failed to stop internal capture device."); return ma_result_from_OpenSL(resultSL); } - - MA_OPENSL_BUFFERQUEUE(pDevice->opensl.pBufferQueueCapture)->Clear((SLAndroidSimpleBufferQueueItf)pDevice->opensl.pBufferQueueCapture); } if (pDevice->type == ma_device_type_playback || pDevice->type == ma_device_type_duplex) { ma_device_drain__opensl(pDevice, ma_device_type_playback); resultSL = MA_OPENSL_PLAY(pDevice->opensl.pAudioPlayer)->SetPlayState((SLPlayItf)pDevice->opensl.pAudioPlayer, SL_PLAYSTATE_STOPPED); + if (resultSL == SL_RESULT_SUCCESS) { + MA_OPENSL_BUFFERQUEUE(pDevice->opensl.pBufferQueuePlayback)->Clear((SLAndroidSimpleBufferQueueItf)pDevice->opensl.pBufferQueuePlayback); + } + + pDevice->opensl.isDrainingPlayback = MA_FALSE; + if (resultSL != SL_RESULT_SUCCESS) { ma_log_post(ma_device_get_log(pDevice), MA_LOG_LEVEL_ERROR, "[OpenSL] Failed to stop internal playback device."); return ma_result_from_OpenSL(resultSL); } - - MA_OPENSL_BUFFERQUEUE(pDevice->opensl.pBufferQueuePlayback)->Clear((SLAndroidSimpleBufferQueueItf)pDevice->opensl.pBufferQueuePlayback); } /* Make sure the client is aware that the device has stopped. There may be an OpenSL|ES callback for this, but I haven't found it. */