From 3d2e98b02e282be49e0e97ca86d346fb3cc6c6ca Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 28 Oct 2026 23:23:49 +0100 Subject: [PATCH] Keep media audio session close retryable until router cleanup succeeds --- .../utun/chat/data/MediaAudioController.kt | 31 +++++++++++++------ .../kotlin/android/bluetooth/HostBluetooth.kt | 4 +++ 2 files changed, 26 insertions(+), 9 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 9649d9ee..1920cdc2 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 @@ -35,7 +35,8 @@ internal object MediaAudioController : MediaAudioSession() * refresh/select асинхронны. attachOutput/close lease/awaitIdle/close блокируют вызывающего worker, * поэтому main и io для них запрещены. Track регистрируется до play и отсоединяется до release. * Ошибки инвалидируют поколение; следующий refresh повторяет попытку, сохраняя действующие PCM-подписки. - * Настройка становится committed только после успешной записи. close требует отсутствия потребителей. + * Настройка становится committed только после успешной записи. close требует отсутствия потребителей, + * запрещает новых и закрывает executor лишь после подтверждения hardware/router cleanup; отказ допускает повтор close. */ internal open class MediaAudioSession(private val io: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor { Thread(it, "media-audio-control").apply { isDaemon = true } }) : AutoCloseable { @@ -44,6 +45,7 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService = private val main = Handler(Looper.getMainLooper()) @Volatile private var controlThread: Thread? = null @Volatile private var closed = false + @Volatile private var closing = false private val router = CallAudioRouter() private val mutable = MutableStateFlow(MediaAudioState()) val state: StateFlow = mutable @@ -110,7 +112,7 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService = /** Не открывает микрофон из UI. Вызывается реальным владельцем захвата с RECORD_AUDIO. */ fun subscribe(name: String): MicrophoneCapture.Subscription = synchronized(inputLock) { - check(!closed) { "Media audio session closed" } + check(!closed && !closing) { "Media audio session closing/closed" } check(!CallController.blocksMedia()) { "Media capture blocked by call" } if (shared?.closed != false) { val device = InputBridge() @@ -124,9 +126,9 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService = /** Регистрация завершается до play, удаление — до release. Не вызывается из main или io. */ fun attachOutput(track: AudioTrack): AutoCloseable { checkBlockingCaller() - check(!closed) { "Media audio session closed" } + check(!closed && !closing) { "Media audio session closing/closed" } val registration = io.submit { - check(!closed) { "Media audio session closed" } + check(!closed && !closing) { "Media audio session closing/closed" } check(!CallController.blocksMedia()) { "Media output blocked by call" } check(track !in outputs) { "AudioTrack already registered" } val owner = OutputRegistration() @@ -181,6 +183,7 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService = fun awaitIdle() { checkBlockingCaller() io.submit { + check(!closing) { "Media audio session cleanup incomplete" } check(outputs.isEmpty() && synchronized(inputLock) { shared?.closed != false }) { "Media audio still owned" } invalidateAttempt() releaseHardware() @@ -196,10 +199,20 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService = io.submit { synchronized(inputLock) { check(outputs.isEmpty() && shared?.closed != false) { "Media audio still owned" } - closed = true + closing = true } invalidateAttempt() - try { releaseHardware() } finally { router.stop() } + var failure: Exception? = null + try { releaseHardware() } catch (e: Exception) { + LogManager.addLog("ERROR", "media_audio", "session hardware cleanup failed; close remains retryable: $e") + failure = e + } + try { router.stop() } catch (e: Exception) { + LogManager.addLog("ERROR", "media_audio", "session router cleanup failed; close remains retryable: $e") + if (failure == null) failure = e + } + failure?.let { throw it } + closed = true LogManager.addLog("INFO", "media_audio", "session closed") }.get() } finally { if (closed) io.shutdown() } @@ -211,9 +224,9 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService = } private fun enqueue(action: () -> Unit) { - if (closed) return + if (closed || closing) return try { - io.execute { if (!closed) try { action() } catch (e: Exception) { recover(e) } } + io.execute { if (!closed && !closing) try { action() } catch (e: Exception) { recover(e) } } } catch (e: RejectedExecutionException) { if (!closed) LogManager.addLog("ERROR", "media_audio", "control task rejected: $e") } @@ -359,7 +372,7 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService = val epoch = generation lateinit var task: ScheduledFuture<*> task = io.schedule({ - if (closed || generation != epoch || confirmationTimeout !== task) return@schedule + if (closed || closing || generation != epoch || confirmationTimeout !== task) return@schedule confirmationTimeout = null try { check(inputReady && routeAvailable && outputsReady(outputs.keys)) { diff --git a/tools/chatgui-android/router-tests/src/main/kotlin/android/bluetooth/HostBluetooth.kt b/tools/chatgui-android/router-tests/src/main/kotlin/android/bluetooth/HostBluetooth.kt index 4d82424e..6d920adc 100644 --- a/tools/chatgui-android/router-tests/src/main/kotlin/android/bluetooth/HostBluetooth.kt +++ b/tools/chatgui-android/router-tests/src/main/kotlin/android/bluetooth/HostBluetooth.kt @@ -30,6 +30,8 @@ class BluetoothAdapter { var closeAttempts = 0 var rejectRequest = false var throwRequest = false + var closeFailures = emptySet() + var closeFailuresAfter = emptySet() var requestEntered: CountDownLatch? = null var requestReturn: CountDownLatch? = null @@ -61,9 +63,11 @@ class BluetoothAdapter { check(profile == BluetoothProfile.HEADSET) if (proxy == null) return ++closeAttempts + check(proxy !in closeFailures) { "BT close failed before effect proxy=$proxy" } if (!live.remove(proxy)) return // SDK close идемпотентен; запись удаляется только один раз. ++closes (proxy as BluetoothHeadset).connected = false LogManager.addLog("INFO", "HostBluetooth", "closed proxy=${proxy.id} live=${live.size}") + check(proxy !in closeFailuresAfter) { "BT close failed after effect proxy=$proxy" } } }