From 2516545f9de6406be5c5fac24caf41356c02fb72 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 01:43:47 +0100 Subject: [PATCH] Retain failed media routing listener removals for acknowledged retry --- .../utun/chat/data/MediaAudioController.kt | 59 +++++++++++++++---- .../main/kotlin/android/media/HostTracks.kt | 3 +- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaAudioController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaAudioController.kt index aec55d0a..f096903d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaAudioController.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaAudioController.kt @@ -64,12 +64,15 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService = private var forcedPhone = false private var savePending = false private var modeOwned = false - private class OutputRegistration { + private class OutputRegistration(val session: Int) { lateinit var listener: AudioRouting.OnRoutingChangedListener var reported = false var deviceId: Int? = null } private val outputs = linkedMapOf() + private val pendingOutputs = linkedMapOf() // Неактивные listeners до SDK ACK, callback не читает track. + private var outputCleanup: ScheduledFuture<*>? = null + private var outputCleanupDelay = 250L private var confirmationTimeout: ScheduledFuture<*>? = null private var wantedOutput: AudioDeviceInfo? = null private var wantedInputName: String? = null @@ -139,8 +142,8 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService = "Media audio session closing/closed" } check(!CallController.blocksMedia()) { "Media output blocked by call" } - check(track !in outputs) { "AudioTrack already registered" } - val owner = OutputRegistration() + check(track !in outputs && track !in pendingOutputs) { "AudioTrack already registered or cleanup pending" } + val owner = OutputRegistration(track.audioSessionId) owner.listener = AudioRouting.OnRoutingChangedListener { enqueue { if (outputs[track] !== owner) return@enqueue @@ -161,9 +164,9 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService = ensureTimeout() confirm() } catch (e: Exception) { - outputs.remove(track) - try { track.removeOnRoutingChangedListener(owner.listener) } catch (cleanup: Exception) { - LogManager.addLog("WARN", "media_audio", "output listener cleanup failed session=${track.audioSessionId}: $cleanup") + try { detachOutput(track, owner) } catch (cleanup: Exception) { + e.addSuppressed(cleanup) + LogManager.addLog("ERROR", "media_audio", "output listener cleanup failed session=${owner.session}; retained: $cleanup") } LogManager.addLog("ERROR", "media_audio", "output registration failed session=${track.audioSessionId}: $e") recover(e) @@ -176,18 +179,48 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService = if (closed) return@AutoCloseable checkBlockingCaller() io.submit { - if (outputs[track] !== registration) return@submit - outputs.remove(track) - try { track.removeOnRoutingChangedListener(registration.listener) } catch (e: Exception) { - LogManager.addLog("WARN", "media_audio", "output listener removal failed session=${track.audioSessionId}: $e") - } + if (outputs[track] !== registration && pendingOutputs[track] !== registration) return@submit + detachOutput(track, registration) try { reconcile() } catch (e: Exception) { recover(e) } - LogManager.addLog("INFO", "media_audio", "output detached session=${track.audioSessionId} count=${outputs.size}") + LogManager.addLog("INFO", "media_audio", "output detached session=${registration.session} count=${outputs.size}") }.get() } } + private fun detachOutput(track: AudioTrack, owner: OutputRegistration) { + outputs.remove(track); pendingOutputs[track] = owner + try { track.removeOnRoutingChangedListener(owner.listener); pendingOutputs.remove(track) } catch (e: Exception) { + LogManager.addLog("ERROR", "media_audio", "output listener removal failed session=${owner.session}; retained: $e") + scheduleOutputCleanup(); throw e + } + if (pendingOutputs.isEmpty()) { outputCleanup?.cancel(false); outputCleanup = null; outputCleanupDelay = 250 } + } + + /** Также очищает listener частично неудавшегося attach, у которого caller не получил lease. */ + private fun cleanupOutputListeners() { + var failure: Exception? = null + for ((track, owner) in pendingOutputs.toMap()) try { detachOutput(track, owner) } catch (e: Exception) { failure = e } + failure?.let { throw it } + } + + private fun scheduleOutputCleanup() { + if (outputCleanup != null) return + val delay = outputCleanupDelay + try { + outputCleanup = io.schedule({ + outputCleanup = null + try { cleanupOutputListeners() } catch (e: Exception) { + LogManager.addLog("WARN", "media_audio", "output listener cleanup pending count=${pendingOutputs.size}: $e") + } + }, delay, TimeUnit.MILLISECONDS) + outputCleanupDelay = (delay * 2).coerceAtMost(30_000) + LogManager.addLog("DEBUG", "media_audio", "output listener cleanup scheduled delayMs=$delay count=${pendingOutputs.size}") + } catch (e: RejectedExecutionException) { + LogManager.addLog("ERROR", "media_audio", "output listener cleanup rejected; owners retained count=${pendingOutputs.size}: $e") + } + } + /** Барьер cleanup после остановки всех движков, перед передачей ресурсов Telecom. */ fun awaitIdle() { checkBlockingCaller() @@ -197,6 +230,7 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService = "Media audio session cleanup incomplete" } check(outputs.isEmpty() && synchronized(inputLock) { shared?.closed != false }) { "Media audio still owned" } + cleanupOutputListeners() invalidateAttempt() releaseHardware() mutable.value = mutable.value.copy(switching = false, inputName = null, outputName = null) @@ -215,6 +249,7 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService = } invalidateAttempt() var failure: Exception? = null + try { cleanupOutputListeners() } catch (e: Exception) { failure = e } try { releaseHardware() } catch (e: Exception) { LogManager.addLog("ERROR", "media_audio", "session hardware cleanup failed; close remains retryable: $e") failure = e diff --git a/tools/chatgui-android/router-tests/src/main/kotlin/android/media/HostTracks.kt b/tools/chatgui-android/router-tests/src/main/kotlin/android/media/HostTracks.kt index 505282b5..e1b935b0 100644 --- a/tools/chatgui-android/router-tests/src/main/kotlin/android/media/HostTracks.kt +++ b/tools/chatgui-android/router-tests/src/main/kotlin/android/media/HostTracks.kt @@ -96,8 +96,9 @@ class AudioTrack(val id: Int) : AudioRouting { access("add listener after") } fun removeOnRoutingChangedListener(listener: AudioRouting.OnRoutingChangedListener) { - access("remove listener") + check("remove listener" !in failures) { "track failed: remove listener" } listeners.remove(listener) + check("remove listener after" !in failures) { "track failed: remove listener after" } } fun setPreferredDevice(device: AudioDeviceInfo?): Boolean { access("prefer"); selectionAttempts++