From c053fdd9ca7204ba9c287500362ad4cb28143261 Mon Sep 17 00:00:00 2001 From: Bhavik Shah Date: Sat, 29 Aug 2026 18:39:20 -0700 Subject: [PATCH] fix: make WaveformExtractor.stop() idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stop() is reachable from two threads: the MediaCodec callback thread, when decoding completes on its own (handleBufferDivision once progress exceeds 1.0, and onOutputBufferAvailable at EOF), and the platform thread, when Flutter calls stopExtraction — which PlayerController.dispose does unconditionally, and which createOrUpdateExtractor does before starting a new extraction on the same key. Because the decoder reference was never cleared and stop() had no guard, the later caller invoked MediaCodec.stop() on an already-released codec: java.lang.IllegalStateException: codec is released already at android.media.MediaCodec.native_stop(Native Method) at android.media.MediaCodec.stop(MediaCodec.java:2579) On the Flutter side this surfaces as a PlatformException thrown out of PlayerController.dispose(), which is `void ... async`, so it escapes as an unhandled async error rather than something callers can catch. Guard stop() with an AtomicBoolean compare-and-set, tolerate IllegalStateException on each release, and null out decoder/extractor afterwards — which also makes the existing null checks in onInputBufferAvailable and onOutputBufferAvailable effective, where previously they could never fire. iOS is unaffected: WaveformExtractor.cancel() is already flag-guarded. --- .../audio_waveforms/WaveformExtractor.kt | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt b/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt index 46201a91..b182ac30 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt @@ -10,6 +10,7 @@ import android.util.Log import io.flutter.plugin.common.MethodChannel import java.nio.ByteBuffer import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.pow import kotlin.math.sqrt import androidx.core.net.toUri @@ -68,6 +69,8 @@ class WaveformExtractor( private var perSamplePoints = 0L /** Flag to prevent submitting multiple results */ private var isReplySubmitted = false + /** Guards [stop] so the codec is only stopped and released once */ + private val isStopped = AtomicBoolean(false) /** * Retrieves the audio format from the given media file @@ -402,9 +405,32 @@ class WaveformExtractor( * 3. Signals completion via the countdown latch */ fun stop() { - decoder?.stop() - decoder?.release() - extractor?.release() + // stop() reaches us from two threads: the MediaCodec callback thread + // when decoding finishes on its own, and the platform thread when + // Flutter calls stopExtraction (PlayerController.dispose does this). + // Whichever loses the race would otherwise call stop() on a codec that + // is already released, which throws IllegalStateException. + if (!isStopped.compareAndSet(false, true)) return + + try { + decoder?.stop() + } catch (e: IllegalStateException) { + Log.w(Constants.LOG_TAG, "Decoder already stopped: ${e.message}") + } + try { + decoder?.release() + } catch (e: IllegalStateException) { + Log.w(Constants.LOG_TAG, "Decoder already released: ${e.message}") + } + decoder = null + + try { + extractor?.release() + } catch (e: IllegalStateException) { + Log.w(Constants.LOG_TAG, "Extractor already released: ${e.message}") + } + extractor = null + finishCount.countDown() } }