From 48ab63a9d3709ad86bf364a7a82cff651c4e56d2 Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 28 Oct 2026 23:26:08 +0100 Subject: [PATCH] Test router cleanup barriers in call and shared media owners --- .../utun/chat/data/CallAudioLifecycleTest.kt | 25 +++++++++++ .../utun/chat/data/MediaAudioSessionTest.kt | 41 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/CallAudioLifecycleTest.kt b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/CallAudioLifecycleTest.kt index bc0c0a7c..936edd11 100644 --- a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/CallAudioLifecycleTest.kt +++ b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/CallAudioLifecycleTest.kt @@ -1,6 +1,7 @@ package com.utun.chat.data import android.content.Context +import android.bluetooth.BluetoothAdapter import android.media.AudioDeviceInfo import android.media.AudioManager import android.media.AudioRecord @@ -29,6 +30,7 @@ class CallAudioLifecycleTest { Build.VERSION.SDK_INT = 30 ChatApplication.instance = Context() AudioManager.shared = AudioManager() + BluetoothAdapter.current = null AudioTrack.created.clear(); AudioTrack.nextFailures = emptySet(); AudioTrack.minimumBuffer = 1920 AudioRecord.created.clear(); AudioRecord.nextFailures = emptySet() NativeLib.callAudioCommands.clear(); NativeLib.callAudioFailures = emptySet(); NativeLib.onCallFeed = {} @@ -224,4 +226,27 @@ class CallAudioLifecycleTest { assertEquals(AudioManager.MODE_IN_COMMUNICATION, manager.mode) assertEquals(false, field("modeOwned")); assertEquals(1, stops()) } + + @Test fun pendingRouterCleanupBlocksNativeStopWithoutSkippingModeOrDeviceRelease() { + start(); manager.failures = setOf("unregister") + rejected { engine.stop() } + assertTrue(AudioRecord.created.single().released); assertTrue(AudioTrack.created.single().released) + assertEquals(AudioManager.MODE_NORMAL, manager.mode); assertEquals(0, stops()) + assertTrue(engine.router.cleanupPending); assertTrue(diagnostic("router stop failed")) + rejected { engine.start(8) } + assertEquals(listOf("start" to 7L), NativeLib.callAudioCommands.toList()) + manager.failures = emptySet(); engine.stop() + assertFalse(engine.router.cleanupPending); assertEquals(1, stops()) + } + + @Test fun failedTelecomHandoffReportsFailureAndRetainsFallbackForCleanup() { + Build.VERSION.SDK_INT = 36 + manager.devices += AudioDeviceInfo(11, AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, true, false, "earpiece") + engine.router.setRoute(AudioRoute.EARPIECE); start() + manager.failures = setOf("clear") + engine.bindTelecom(UtunConnection()); main.dispatchAll() + assertTrue(failures.any { "Telecom routing handoff failed" in it }); assertNull(engine.router.telecom) + rejected { engine.stop() }; assertEquals(0, stops()) + manager.failures = emptySet(); engine.stop(); assertEquals(1, stops()) + } } diff --git a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/MediaAudioSessionTest.kt b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/MediaAudioSessionTest.kt index 6853d0f8..59a9f632 100644 --- a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/MediaAudioSessionTest.kt +++ b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/MediaAudioSessionTest.kt @@ -361,6 +361,47 @@ class MediaAudioSessionTest { assertTrue(tracks.all { it.listeners.isEmpty() }) } + @Test fun failedRouterCloseKeepsExecutorAvailableAndRejectsNewConsumersUntilRetry() { + start() + val callback = audio.callbacks.single() + audio.failures = setOf("unregister") + assertThrows(IllegalStateException::class.java) { background { owner.close() } } + assertFalse(clock.isShutdown); assertEquals(setOf(callback), audio.callbacks) + assertThrows(IllegalStateException::class.java) { owner.subscribe("radio") } + assertThrows(IllegalStateException::class.java) { background { owner.attachOutput(track()) } } + assertThrows(IllegalStateException::class.java) { background { owner.awaitIdle() } } + val registrations = audio.callbackHistory.size + owner.refresh(); owner.select(MediaAudioProfile.WIRED); callback.onAudioDevicesAdded(emptyArray()); drain() + assertEquals(registrations, audio.callbackHistory.size) + audio.failures = emptySet(); background { owner.close() } + assertTrue(clock.isShutdown); assertTrue(audio.callbacks.isEmpty()) + diagnostic("session router cleanup failed") + } + + @Test fun bluetoothCloseFailureKeepsSessionCloseRetryable() { + val adapter = BluetoothAdapter().also { BluetoothAdapter.current = it } + try { + start(); adapter.connect(0); drain() + adapter.closeFailures = adapter.live.toSet() + assertThrows(IllegalStateException::class.java) { background { owner.close() } } + assertFalse(clock.isShutdown); assertEquals(1, adapter.live.size); assertTrue(audio.callbacks.isEmpty()) + adapter.closeFailures = emptySet(); background { owner.close() } + assertTrue(clock.isShutdown); assertTrue(adapter.live.isEmpty()) + } finally { adapter.closeFailures = emptySet() } + } + + @Test fun failedHardwareCloseDoesNotSkipRouterCleanupOrDisableRetry() { + start(); val sub = subscribe() + AndroidMicrophoneCapture.failClose = true + sub.close(); drain() + assertThrows(IllegalStateException::class.java) { background { owner.close() } } + assertFalse(clock.isShutdown); assertTrue(audio.callbacks.isEmpty()) + assertTrue(AndroidMicrophoneCapture.opened.any { !it.closed }) + AndroidMicrophoneCapture.failClose = false; background { owner.close() } + assertTrue(clock.isShutdown); assertTrue(AndroidMicrophoneCapture.opened.all { it.closed }) + diagnostic("session hardware cleanup failed") + } + @Test fun callReservationBlocksSelectionCaptureAndOutput() { start(); CallController.blocked = true select(MediaAudioProfile.WIRED)