diff --git a/tools/chatgui-android/capture-tests/src/main/kotlin/android/media/HostVoicePlayback.kt b/tools/chatgui-android/capture-tests/src/main/kotlin/android/media/HostVoicePlayback.kt index 005c378c..45de8754 100644 --- a/tools/chatgui-android/capture-tests/src/main/kotlin/android/media/HostVoicePlayback.kt +++ b/tools/chatgui-android/capture-tests/src/main/kotlin/android/media/HostVoicePlayback.kt @@ -9,10 +9,11 @@ object AudioManager { const val STREAM_MUSIC = 3 } class AudioTrack(stream: Int, rate: Int, mask: Int, encoding: Int, bytes: Int, mode: Int) { class Plan { var initialized = true - var failures = emptySet() + @Volatile var failures = emptySet() val results = LinkedBlockingQueue() val accepted = CopyOnWriteArrayList() var releaseCalls = 0 + @Volatile var released = false } companion object { const val MODE_STREAM = 1; const val STATE_INITIALIZED = 1 @@ -32,5 +33,8 @@ class AudioTrack(stream: Int, rate: Int, mask: Int, encoding: Int, bytes: Int, m return result } fun stop() { check("stop" !in plan.failures) { "track stop failed" } } - fun release() { plan.releaseCalls++; check("release" !in plan.failures) { "track release failed" } } + fun release() { + plan.releaseCalls++; check("release" !in plan.failures) { "track release failed" }; plan.released = true + check("release after" !in plan.failures) { "track release failed after effect" } + } } 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 index c6cb0d00..3242ac3b 100644 --- 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 @@ -17,11 +17,14 @@ object NativeLib { val cancels = AtomicInteger() val decoderReads = LinkedBlockingQueue() val decoderCloses = AtomicInteger() + @Volatile var failDecoderClose = false + @Volatile var failCancel = false var decoderInfo = intArrayOf(48000, 1, 960) fun reset() { active = false; acceptStart = true; acceptFeed = true; stopResult = 800; onFeed = {} starts.clear(); feeds.clear(); stops.set(0); cancels.set(0) decoderReads.clear(); decoderCloses.set(0); decoderInfo = intArrayOf(48000, 1, 960) + failDecoderClose = false; failCancel = false } @Synchronized fun voiceStart(channel: String, reply: String = "", request: Long = 0, generation: Long = 0): Boolean { starts.add(listOf(channel, reply, request, generation)) @@ -30,14 +33,14 @@ object NativeLib { } fun voiceFeed(pcm: ShortArray): Boolean { feeds.add(pcm.copyOf()); onFeed(); return active && acceptFeed } @Synchronized fun voiceStop(): Int { stops.incrementAndGet(); active = false; return stopResult } - @Synchronized fun voiceCancel() { cancels.incrementAndGet(); active = false } + @Synchronized fun voiceCancel() { check(!failCancel) { "native cancel rejected" }; 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() } + fun voiceDecodeClose(handle: Long) { check(!failDecoderClose) { "decoder close rejected" }; 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 index 79456c45..c5119f66 100644 --- 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 @@ -21,9 +21,11 @@ class AudioRecorderManagerTest { ChatApplication.instance = RecorderHostContext(); LogManager.entries.clear() } @After fun release() { + NativeLib.failCancel = false; NativeLib.failDecoderClose = false 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() + manager.requestClose().get(3, TimeUnit.SECONDS) AudioRecord.created.forEach { it.plan.reader?.join(2_000) assertFalse("reader survived cleanup", it.plan.reader?.isAlive == true) @@ -176,4 +178,69 @@ class AudioRecorderManagerTest { manager.playVoiceFile("voice.opus", onComplete = { done.countDown() }) assertTrue(done.await(2, TimeUnit.SECONDS)); assertEquals(1, NativeLib.decoderCloses.get()); diagnostic("decode") } + + @Test fun disposedViewModelLeavesAProcessOwnerUntilCaptureReleaseIsAcknowledged() { + val device = plan(); assertTrue(manager.startRecording("7")); device.failures = setOf("release") + val closed = manager.requestClose() + waitUntil { NativeLib.cancels.get() == 1 } + assertFalse(closed.isDone); assertFalse(NativeLib.active) + assertThrows(IllegalStateException::class.java) { AudioRecorderManager.requireReleasedForCore() } + device.failures = emptySet(); closed.get(2, TimeUnit.SECONDS) + assertTrue(device.released); AudioRecorderManager.requireReleasedForCore() + } + + @Test fun nativeCancelFailureRetainsTheCaptureUntilARealAcknowledgement() { + plan(); assertTrue(manager.startRecording("7")); NativeLib.failCancel = true + assertEquals(-1, manager.stopRecording()); assertTrue(NativeLib.active) + val closed = manager.requestClose(); assertFalse(closed.isDone) + assertThrows(IllegalStateException::class.java) { AudioRecorderManager.requireReleasedForCore() } + NativeLib.failCancel = false; closed.get(2, TimeUnit.SECONDS) + assertFalse(NativeLib.active); AudioRecorderManager.requireReleasedForCore() + } + + @Test fun playbackReleaseFailureDoesNotLoseItsDeviceAfterCompletionCallback() { + val output = AudioTrack.Plan().also { it.failures = setOf("release"); AudioTrack.plans.add(it) } + val completed = CountDownLatch(1) + manager.playVoiceFile("voice.opus", onComplete = { completed.countDown() }) + assertTrue(completed.await(2, TimeUnit.SECONDS)); assertEquals(1, NativeLib.decoderCloses.get()) + val closed = manager.requestClose(); assertFalse(closed.isDone); assertFalse(output.released) + output.failures = emptySet(); closed.get(2, TimeUnit.SECONDS) + assertTrue(output.released); assertTrue(output.releaseCalls >= 2) + } + + @Test fun releaseAfterEffectStillNeedsAcknowledgementAndDoesNotCloseDecoderTwice() { + val output = AudioTrack.Plan().also { it.failures = setOf("release after"); AudioTrack.plans.add(it) } + val completed = CountDownLatch(1) + manager.playVoiceFile("voice.opus", onComplete = { completed.countDown() }) + assertTrue(completed.await(2, TimeUnit.SECONDS)); assertTrue(output.released) + val closed = manager.requestClose(); assertFalse(closed.isDone) + output.failures = emptySet(); closed.get(2, TimeUnit.SECONDS) + assertEquals(1, NativeLib.decoderCloses.get()) + } + + @Test fun decoderCloseFailureDoesNotSkipTheIndependentTrackRelease() { + NativeLib.failDecoderClose = true + val output = AudioTrack.Plan().also { AudioTrack.plans.add(it) }; val completed = CountDownLatch(1) + manager.playVoiceFile("voice.opus", onComplete = { completed.countDown() }) + assertTrue(completed.await(2, TimeUnit.SECONDS)); assertTrue(output.released) + val closed = manager.requestClose(); assertFalse(closed.isDone) + NativeLib.failDecoderClose = false; closed.get(2, TimeUnit.SECONDS) + assertEquals(1, NativeLib.decoderCloses.get()); assertEquals(1, output.releaseCalls) + } + + @Test fun blockedNativeFeedCannotHoldMainWhileAnAsynchronousCloseIsPending() { + val entered = CountDownLatch(1); val leave = CountDownLatch(1) + NativeLib.onFeed = { + entered.countDown() + while (leave.count != 0L) try { leave.await() } catch (_: InterruptedException) {} + } + val device = plan(); assertTrue(manager.startRecording("7")); device.chunks.add(AudioRecord.Chunk()) + assertTrue(entered.await(1, TimeUnit.SECONDS)) + val closed = manager.requestClose() + try { + assertFalse(closed.isDone); assertFalse(manager.startRecording("8")) + assertThrows(IllegalStateException::class.java) { AudioRecorderManager.requireReleasedForCore() } + } finally { leave.countDown() } + closed.get(2, TimeUnit.SECONDS); assertTrue(device.released); assertFalse(NativeLib.active) + } }