Reproduce voice capture rollback and playback cleanup failures

This commit is contained in:
evgeny committed 2026-10-28 11:36:13 +01:00
1 parent 537814e7fd
commit 0219624c7d
4 files changed
+198 -2

No files matched your search

@@ -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")
}
}
@@ -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<Int>()
@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()
@@ -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<List<Any>>()
val feeds = CopyOnWriteArrayList<ShortArray>()
val stops = AtomicInteger()
val cancels = AtomicInteger()
val decoderReads = LinkedBlockingQueue<Int>()
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 }
@@ -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")
}
}