Guard viewer startup and cover the native teardown barrier

This commit is contained in:
evgeny committed 2026-10-29 13:34:24 +01:00
1 parent 495d399691
commit ee20c8b9b7
2 files changed
+33 -2

No files matched your search

@@ -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
}
}
}
@@ -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())
}
}