From b7d7689a1e37d397359c0fd2939e41f36db3d73f Mon Sep 17 00:00:00 2001 From: "muthuraj.mr" Date: Sat, 24 Oct 2026 16:31:31 +0530 Subject: [PATCH] fix(windows): don't tear down the native player while an open/seek or frame read is using it Two races in dispose() crashed with EXCEPTION_ACCESS_VIOLATION (#249): - It never took mediaOperationMutex, so disposing while openUri was inside native OpenMedia destroyed the instance underneath it (reproduced: removing a playing player from composition shortly after opening crashes in nOpenMedia). dispose() now tears down while holding the mutex, waiting up to 2 s; if an open is still blocked in native code after that (slow HTTP connect), the teardown finishes on a background thread once it returns. - It waited while jobToJoin.isActive after cancel(), but a cancelled job is no longer active, so it never waited for the producer to leave ReadVideoFrame. It now waits for isCompleted. --- .../windows/WindowsVideoPlayerState.kt | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt index a2eb89e7..d1774ab1 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt @@ -61,6 +61,9 @@ class WindowsVideoPlayerState : VideoPlayerState { companion object { private val isMfBootstrapped = AtomicBoolean(false) + /** How long dispose() waits for an open/seek in native code before finishing the teardown in the background. */ + private const val DISPOSE_LOCK_TIMEOUT_NS = 2_000_000_000L + /** Map to store volume settings for each player instance */ private val instanceVolumes = ConcurrentHashMap() @@ -338,6 +341,36 @@ class WindowsVideoPlayerState : VideoPlayerState { return // Already disposing } + // An open or seek in progress may be inside native code (OpenMedia, SeekMedia) with this instance; destroying + // it underneath crashes the process (EXCEPTION_ACCESS_VIOLATION, #249). Tear down only while holding + // mediaOperationMutex. Spin rather than suspend: dispose often runs on the EDT. + val deadlineNs = System.nanoTime() + DISPOSE_LOCK_TIMEOUT_NS + var locked = mediaOperationMutex.tryLock() + while (!locked && System.nanoTime() < deadlineNs) { + try { + Thread.sleep(5) + } catch (_: InterruptedException) { + break + } + locked = mediaOperationMutex.tryLock() + } + if (locked) { + try { + releaseNativePlayer() + } finally { + mediaOperationMutex.unlock() + } + scope.cancel() + } else { + // Still blocked in native code (e.g. a slow HTTP connect in OpenMedia): finish once it returns. + Thread({ + kotlinx.coroutines.runBlocking { mediaOperationMutex.withLock { releaseNativePlayer() } } + scope.cancel() + }, "ComposeMediaPlayer-dispose").start() + } + } + + private fun releaseNativePlayer() { // Stop coroutines first. The producer reads native state under // videoReaderMutex; we must wait for it to exit its critical section // before tearing down the native reader, otherwise CloseMedia can @@ -371,7 +404,9 @@ class WindowsVideoPlayerState : VideoPlayerState { // stalls. val deadlineNs = System.nanoTime() + 500_000_000L if (java.awt.EventQueue.isDispatchThread()) { - while (jobToJoin.isActive && System.nanoTime() < deadlineNs) { + // isActive is already false right after cancel(); wait until the job (and the + // producer's native read) has actually finished. + while (!jobToJoin.isCompleted && System.nanoTime() < deadlineNs) { try { Thread.sleep(10) } catch (_: InterruptedException) { break } } } else { @@ -402,8 +437,6 @@ class WindowsVideoPlayerState : VideoPlayerState { windowsLogger.e { "Exception destroying instance: ${e.message}" } } } - - scope.cancel() } private fun releaseAllResources() {