diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt index 11761a92..62566927 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt @@ -68,6 +68,7 @@ class StreamPlayerEngine(private val group: Long, private val source: Long, priv private val id = StreamController.nextId() private var requestedAtUs = 0L @Volatile private var running = false + private var stopped = false // Main: stop до start также запрещает запуск этого одноразового viewer. @Volatile var drainTimeoutMs = 2000L private set private var worker: Thread? = null @@ -76,6 +77,8 @@ class StreamPlayerEngine(private val group: Long, private val source: Long, priv private val releaseCallbacks = mutableListOf<() -> Unit>() fun start(surface: Surface) { + check(Looper.myLooper() == Looper.getMainLooper()) { "Viewer start requires main" } + if (stopped) { LogManager.addLog("WARN", "stream", "viewer start after stop ignored id=$id"); return } if (worker != null) return if (CallController.blocksMedia()) { LogManager.addLog("WARN", "stream", "viewer blocked by call id=$id") @@ -108,10 +111,18 @@ class StreamPlayerEngine(private val group: Long, private val source: Long, priv if (!main.post { players.remove(this); mutableViewing.value = players.isNotEmpty() }) LogManager.addLog("ERROR", "stream", "viewer completion post rejected id=$id; registry retains owner") } - }, "stream-audio-$id").apply { start() } + }, "stream-audio-$id") + try { worker!!.start() } catch (e: Exception) { + running = false; stopped = true + synchronized(releaseLock) { released = true } + players.remove(this); mutableViewing.value = players.isNotEmpty(); mutableStatus.value = R.string.stream_failed + LogManager.addLog("ERROR", "stream", "viewer worker start failed id=$id; no native owners started: $e") + } } /** Surface/SurfaceTexture освобождаются callback только после остановки decoder и native DSP. */ fun stop(onReleased: (() -> Unit)? = null) { + check(Looper.myLooper() == Looper.getMainLooper()) { "Viewer stop requires main" } + stopped = true running = false try { NativeLib.streamStop(id) } catch (e: Exception) { LogManager.addLog("ERROR", "stream", "viewer stop request failed id=$id; worker retains cleanup: $e") @@ -120,7 +131,10 @@ class StreamPlayerEngine(private val group: Long, private val source: Long, priv val immediate = synchronized(releaseLock) { if (worker == null || released) true else { releaseCallbacks.add(onReleased); false } } - if (immediate) onReleased() + if (immediate) try { onReleased() } catch (e: Exception) { + LogManager.addLog("ERROR", "stream", "late viewer release callback failed id=$id; caller retains ownership: $e") + throw e + } } } diff --git a/tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/StreamPlayerEngineLifecycleTest.kt b/tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/StreamPlayerEngineLifecycleTest.kt index c1ecb10c..c8991aa2 100644 --- a/tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/StreamPlayerEngineLifecycleTest.kt +++ b/tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/StreamPlayerEngineLifecycleTest.kt @@ -53,6 +53,7 @@ class StreamPlayerEngineLifecycleTest { @Test fun failedOpenStillCompletesCleanupAndTheReleaseCallback() { HostStreams.failures = setOf("open"); val viewer = viewer(); val released = AtomicInteger() + await("open refusal reported") { viewer.status.value == com.utun.chat.R.string.stream_failed } viewer.stop { released.incrementAndGet() }; await("failed open callback") { released.get() == 1 } assertTrue(HostStreams.signals.isEmpty()) } @@ -98,9 +99,25 @@ class StreamPlayerEngineLifecycleTest { val old = AudioTrack.created.single(); old.failures = setOf("release") first.stop(); await("first viewer released") { !StreamPlayerEngine.viewing.value } assertThrows(IllegalStateException::class.java) { StreamAudioMixer.awaitIdle() } + assertThrows(IllegalStateException::class.java) { StreamPlayerEngine.stopForCore() } val second = viewer(key = true, audio = true); await("second playback ready") { HostStreams.playbacks.isNotEmpty() } assertEquals(1, AudioTrack.created.size); assertFalse(old.released) old.failures = emptySet(); await("second output opens after old release") { AudioTrack.created.size == 2 } assertTrue(old.released); second.stop() } + + @Test fun coreTeardownCannotPassAViewerWithUnacknowledgedNativeResources() { + val viewer = viewer(); await("viewer signal opened") { HostStreams.signals.size == 1 } + HostStreams.failures = setOf("signal close") + assertThrows(IllegalStateException::class.java) { StreamPlayerEngine.stopForCore() } + await("other native owners close") { HostStreams.playbacks.isEmpty() && HostStreams.ports.isEmpty() } + assertThrows(IllegalStateException::class.java) { StreamPlayerEngine.stopForCore() } + HostStreams.failures = emptySet(); await("viewer barrier released") { !StreamPlayerEngine.viewing.value } + StreamPlayerEngine.stopForCore() + } + @Test fun stopBeforeStartLeavesTheViewerPassive() { + val viewer = StreamPlayerEngine(1, 2, 3).also { viewers.add(it) } + viewer.stop(); val surface = Surface().also { surfaces.add(it) }; viewer.start(surface) + assertFalse(StreamPlayerEngine.viewing.value); assertTrue(HostStreams.ports.isEmpty()) + } }