From 0219624c7d2bc32debbbf10e24253dbfca69ebdc Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 28 Oct 2026 10:29:29 +0100 Subject: [PATCH] Reproduce voice capture rollback and playback cleanup failures --- .../capture-tests/build.gradle.kts | 6 +- .../kotlin/android/media/HostCaptureAudio.kt | 2 + .../utun/chat/data/RecorderHostEnvironment.kt | 42 +++++ .../chat/data/AudioRecorderManagerTest.kt | 150 ++++++++++++++++++ 4 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 tools/chatgui-android/capture-tests/src/main/kotlin/com/utun/chat/data/RecorderHostEnvironment.kt create mode 100644 tools/chatgui-android/capture-tests/src/test/kotlin/com/utun/chat/data/AudioRecorderManagerTest.kt diff --git a/tools/chatgui-android/capture-tests/build.gradle.kts b/tools/chatgui-android/capture-tests/build.gradle.kts index d16efffa..67cd74f1 100644 --- a/tools/chatgui-android/capture-tests/build.gradle.kts +++ b/tools/chatgui-android/capture-tests/build.gradle.kts @@ -5,8 +5,10 @@ kotlin { sourceSets.named("main") { // Настоящие reader/очереди, независимый от router-tests Android API без аппаратных ресурсов. kotlin.srcDir("../app/src/main/java") - kotlin.include("android/**", "com/utun/chat/data/AndroidMicrophoneCapture.kt", - "com/utun/chat/data/MicrophoneCapture.kt", "com/utun/chat/data/CaptureHostLog.kt") + kotlin.include("android/**", "androidx/**", "com/utun/chat/data/AndroidMicrophoneCapture.kt", + "com/utun/chat/data/MicrophoneCapture.kt", "com/utun/chat/data/CaptureHostLog.kt", + "com/utun/chat/RecorderHostApp.kt", "com/utun/chat/data/RecorderHostEnvironment.kt", + "com/utun/chat/data/AudioRecorderManager.kt") } } diff --git a/tools/chatgui-android/capture-tests/src/main/kotlin/android/media/HostCaptureAudio.kt b/tools/chatgui-android/capture-tests/src/main/kotlin/android/media/HostCaptureAudio.kt index 51f64169..dbd4a5c8 100644 --- a/tools/chatgui-android/capture-tests/src/main/kotlin/android/media/HostCaptureAudio.kt +++ b/tools/chatgui-android/capture-tests/src/main/kotlin/android/media/HostCaptureAudio.kt @@ -42,6 +42,7 @@ class AudioRecord(val source: Int, val rate: Int, val mask: Int, encoding: Int, val stopCalls = AtomicInteger() val releaseCalls = AtomicInteger() val modes = CopyOnWriteArrayList() + @Volatile var reader: Thread? = null var released = false } companion object { @@ -86,6 +87,7 @@ class AudioRecord(val source: Int, val rate: Int, val mask: Int, encoding: Int, fun startRecording() { operation("start"); if (plan.starts) recording = RECORDSTATE_RECORDING; operation("start after") } fun read(pcm: ShortArray, offset: Int, size: Int) = read(pcm, offset, size, READ_BLOCKING) fun read(pcm: ShortArray, offset: Int, size: Int, mode: Int): Int { + plan.reader = Thread.currentThread() plan.readCalls.incrementAndGet(); plan.modes.add(mode); plan.readEntered.countDown() operation("read") plan.holdRead?.await() diff --git a/tools/chatgui-android/capture-tests/src/main/kotlin/com/utun/chat/data/RecorderHostEnvironment.kt b/tools/chatgui-android/capture-tests/src/main/kotlin/com/utun/chat/data/RecorderHostEnvironment.kt new file mode 100644 index 00000000..05723a6f --- /dev/null +++ b/tools/chatgui-android/capture-tests/src/main/kotlin/com/utun/chat/data/RecorderHostEnvironment.kt @@ -0,0 +1,42 @@ +package com.utun.chat.data + +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.atomic.AtomicInteger + +/** Native singleton и decoder handles заменены только управляемой границей; recorder работает настоящий. */ +object NativeLib { + @Volatile var active = false + var acceptStart = true + var acceptFeed = true + var stopResult = 800 + val starts = CopyOnWriteArrayList>() + val feeds = CopyOnWriteArrayList() + val stops = AtomicInteger() + val cancels = AtomicInteger() + val decoderReads = LinkedBlockingQueue() + val decoderCloses = AtomicInteger() + var decoderInfo = intArrayOf(48000, 1, 960) + fun reset() { + active = false; acceptStart = true; acceptFeed = true; stopResult = 800 + starts.clear(); feeds.clear(); stops.set(0); cancels.set(0) + decoderReads.clear(); decoderCloses.set(0); decoderInfo = intArrayOf(48000, 1, 960) + } + @Synchronized fun voiceStart(channel: String, reply: String = "", request: Long = 0, generation: Long = 0): Boolean { + starts.add(listOf(channel, reply, request, generation)) + if (!acceptStart || active) return false + active = true; return true + } + fun voiceFeed(pcm: ShortArray): Boolean { feeds.add(pcm.copyOf()); return active && acceptFeed } + @Synchronized fun voiceStop(): Int { stops.incrementAndGet(); active = false; return stopResult } + @Synchronized fun voiceCancel() { cancels.incrementAndGet(); active = false } + fun voiceDecodeOpen(path: String, info: IntArray): Long { decoderInfo.copyInto(info); return 1 } + fun voiceDecodeRead(handle: Long, pcm: ShortArray): Int { + val count = decoderReads.poll() ?: 0 + if (count > 0) pcm.fill(12, 0, count) + return count + } + fun voiceDecodeClose(handle: Long) { decoderCloses.incrementAndGet() } +} + +object StreamController { var blocked = false; fun blocksCapture() = blocked } diff --git a/tools/chatgui-android/capture-tests/src/test/kotlin/com/utun/chat/data/AudioRecorderManagerTest.kt b/tools/chatgui-android/capture-tests/src/test/kotlin/com/utun/chat/data/AudioRecorderManagerTest.kt new file mode 100644 index 00000000..10f28e21 --- /dev/null +++ b/tools/chatgui-android/capture-tests/src/test/kotlin/com/utun/chat/data/AudioRecorderManagerTest.kt @@ -0,0 +1,150 @@ +package com.utun.chat.data + +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.AudioTrack +import com.utun.chat.ChatApplication +import com.utun.chat.RecorderHostContext +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** Настоящий lifecycle записи/плеера: отказы Android/JNI и частичные записи PCM без аппаратных устройств. */ +class AudioRecorderManagerTest { + private val manager = AudioRecorderManager() + @Before fun prepare() { + AudioRecord.reset(); AudioTrack.reset(); NativeLib.reset(); StreamController.blocked = false + ChatApplication.instance = RecorderHostContext(); LogManager.entries.clear() + } + @After fun release() { + AudioRecord.created.forEach { it.plan.failures = emptySet(); it.plan.chunks.offer(AudioRecord.Chunk(count = -3)) } + AudioTrack.created.forEach { it.plan.failures = emptySet() } + manager.cancelRecording(); manager.stopMicTest(); manager.stopPlayback() + AudioRecord.created.forEach { + it.plan.reader?.join(2_000) + assertFalse("reader survived cleanup", it.plan.reader?.isAlive == true) + if (!it.plan.released) it.release() + } + } + private fun plan() = AudioRecord.Plan().also { AudioRecord.plans.add(it) } + private fun waitUntil(done: () -> Boolean) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2) + while (!done() && System.nanoTime() < deadline) Thread.sleep(2) + assertTrue("worker did not reach expected state", done()) + } + private fun diagnostic(fragment: String) = assertTrue("missing diagnostic: $fragment", + LogManager.entries.any { it.level in listOf("WARN", "ERROR") && fragment in it.message }) + + @Test fun startupFailureMatrixRollsBackAndroidAndNativeOwnership() { + for (operation in listOf("state", "start", "start after", "recording")) { + val device = plan().apply { failures = setOf(operation) } + val result = runCatching { manager.startRecording("7") } + assertEquals("startup escaped: $operation ${result.exceptionOrNull()}", false, result.getOrNull()) + assertFalse(manager.isActive()); assertFalse(NativeLib.active) + assertTrue("unreleased device: $operation", device.released) + } + } + + @Test fun uninitializedOrNotRecordingDeviceIsRejected() { + for (ready in listOf(false, true)) { + val device = plan().apply { initialized = ready; starts = false } + assertFalse(manager.startRecording("7")) + assertFalse(NativeLib.active); assertTrue(device.released) + } + } + + @Test fun invalidMinimumDoesNotCreateOrPublishARecorder() { + AudioRecord.minimums[AudioFormat.CHANNEL_IN_MONO] = -2 + plan() + assertFalse(manager.startRecording("7")); assertFalse(NativeLib.active) + assertTrue(AudioRecord.created.isEmpty()); diagnostic("minimum") + } + + @Test fun rejectedNativeTestDoesNotCaptureOrCancelAnotherOwner() { + NativeLib.active = true + plan() + assertFalse(manager.startMicTest()) + assertTrue(AudioRecord.created.isEmpty()); assertTrue(NativeLib.active) + assertEquals(0, NativeLib.cancels.get()) + } + + @Test fun stoppingAnInactiveTestKeepsTheMessageRecording() { + plan(); assertTrue(manager.startRecording("7")) + manager.stopMicTest() + assertTrue(manager.isActive()); assertTrue(NativeLib.active) + manager.cancelRecording(); assertFalse(NativeLib.active) + } + + @Test fun failedFeedAndReadDiscardInsteadOfPublishingPartialVoice() { + for (feedFailure in listOf(false, true)) { + NativeLib.acceptFeed = !feedFailure + val device = plan() + assertTrue(manager.startRecording("7")) + device.chunks.add(AudioRecord.Chunk(count = if (feedFailure) 960 else -6)) + waitUntil { if (feedFailure) NativeLib.feeds.isNotEmpty() else device.readCalls.get() > 0 } + waitUntil { !manager.isActive() } + assertEquals(-1, manager.stopRecording()) + assertFalse(NativeLib.active); assertEquals(0, NativeLib.stops.get()); assertTrue(device.released) + } + } + + @Test fun releaseFailureStillCancelsTheOwnedNativeRecording() { + val device = plan(); assertTrue(manager.startRecording("7")) + device.failures = setOf("release") + val result = runCatching { manager.stopRecording() } + assertEquals(-1, result.getOrNull()); assertFalse(NativeLib.active) + assertEquals(1, NativeLib.cancels.get()); diagnostic("release") + } + + @Test fun testReaderIsStoppedBeforeJoinAndStopFailureCannotHangIt() { + val device = plan(); assertTrue(manager.startMicTest()) + assertTrue(device.readEntered.await(1, TimeUnit.SECONDS)) + device.failures = setOf("stop") + val done = CountDownLatch(1) + val worker = Thread({ try { manager.stopMicTest() } finally { done.countDown() } }, "voice-test-stop") + worker.start() + try { assertTrue("test cleanup waited for a blocked read", done.await(300, TimeUnit.MILLISECONDS)) } + finally { + device.failures = emptySet(); device.chunks.offer(AudioRecord.Chunk(count = -3)); worker.join(2_000) + } + assertTrue(device.released); assertFalse(NativeLib.active) + } + + @Test fun partialOutputWritesPreserveEveryDecodedSample() { + val output = AudioTrack.Plan().also { AudioTrack.plans.add(it); it.results.add(240) } + NativeLib.decoderReads.add(960) + val done = CountDownLatch(1) + manager.playVoiceFile("voice.opus", onComplete = { done.countDown() }) + assertTrue(done.await(2, TimeUnit.SECONDS)) + assertEquals(960, output.accepted.size); assertTrue(output.accepted.all { it == 12.toShort() }) + assertEquals(1, NativeLib.decoderCloses.get()) + } + + @Test fun failedOutputWriteIsNotReportedAsPlayedPcm() { + AudioTrack.plans.add(AudioTrack.Plan().apply { results.add(-6) }) + NativeLib.decoderReads.add(960) + val progress = AtomicInteger(); val done = CountDownLatch(1) + manager.playVoiceFile("voice.opus", onProgress = { _, _ -> progress.incrementAndGet() }, onComplete = { done.countDown() }) + assertTrue(done.await(2, TimeUnit.SECONDS)) + assertEquals(0, progress.get()); assertEquals(1, NativeLib.decoderCloses.get()); diagnostic("write") + } + + @Test fun trackCleanupFailureDoesNotLeakDecoderOrLoseCompletion() { + val output = AudioTrack.Plan().also { AudioTrack.plans.add(it); it.failures = setOf("stop", "release") } + val done = CountDownLatch(1) + manager.playVoiceFile("voice.opus", onComplete = { done.countDown() }) + assertTrue(done.await(2, TimeUnit.SECONDS)) + assertEquals(1, NativeLib.decoderCloses.get()); assertEquals(1, output.releaseCalls); diagnostic("release") + } + + @Test fun decoderErrorHasADiagnosticAndClosesItsHandle() { + AudioTrack.plans.add(AudioTrack.Plan()); NativeLib.decoderReads.add(-1) + val done = CountDownLatch(1) + manager.playVoiceFile("voice.opus", onComplete = { done.countDown() }) + assertTrue(done.await(2, TimeUnit.SECONDS)); assertEquals(1, NativeLib.decoderCloses.get()); diagnostic("decode") + } +}