Make Android microphone capture stoppable and cleanup resilient
Use nonblocking AudioRecord reads with data and timestamp deadlines. Serialize idempotent close, finish interrupted joins before release, isolate callback failures, and abort stereo fallback when partial-record cleanup fails. Close failed shared readers to new subscriptions, roll back worker startup, isolate observers, preserve interrupt state, and release the last consumer exactly once. Add an independent capture-tests JVM module to the standard Android unit-test task. Validation: 38 capture scenarios (25 AudioRecord plus 13 PCM/PTT), 455 JVM tests, assembleDebug, core build, and full check.sh (225 tests plus proxy/burst/load) passed.
This commit is contained in:
1 parent
74035fc043
commit
005d136dc7
10 files changed
+776
-56
No files matched your search
@@ -4,6 +4,9 @@ import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.FutureTask
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MicrophoneCaptureTest {
|
||||
private class Device : MicrophoneCapture.Device {
|
||||
@@ -67,4 +70,150 @@ class MicrophoneCaptureTest {
|
||||
assertEquals(MicrophoneCapture.Ptt(true), radio.poll()); assertEquals(MicrophoneCapture.Ptt(false), radio.poll())
|
||||
} finally { radio.close(); stream.close() }
|
||||
}
|
||||
|
||||
private class FaultyDevice : MicrophoneCapture.Device {
|
||||
override val channels = 2
|
||||
override val inputId = 7
|
||||
val input = LinkedBlockingQueue<Any>()
|
||||
val entered = CountDownLatch(1)
|
||||
val stopped = CountDownLatch(1)
|
||||
val stops = AtomicInteger()
|
||||
val closes = AtomicInteger()
|
||||
var failStop = false
|
||||
var failClose = false
|
||||
var onRead: () -> Unit = {}
|
||||
override fun read(): MicrophoneCapture.Frame {
|
||||
entered.countDown()
|
||||
val event = input.take()
|
||||
onRead()
|
||||
if (event is Exception) throw event
|
||||
return event as MicrophoneCapture.Frame
|
||||
}
|
||||
override fun stop() {
|
||||
stops.incrementAndGet(); stopped.countDown()
|
||||
check(!failStop) { "device stop denied" }
|
||||
input.offer(MicrophoneCapture.Frame(shortArrayOf(), 0, 0))
|
||||
}
|
||||
override fun close() { closes.incrementAndGet(); check(!failClose) { "device close denied" } }
|
||||
}
|
||||
|
||||
private fun diagnostic(logs: LinkedBlockingQueue<String>, fragment: String) = assertTrue("Missing $fragment", logs.any { it.contains(fragment) })
|
||||
|
||||
@Test fun readFailureClosesCaptureRejectsSubscribeAndDetachedPollIsEmpty() {
|
||||
val device = FaultyDevice(); val failed = CountDownLatch(1)
|
||||
val capture = MicrophoneCapture(device, { _, text -> if (text.startsWith("capture reader failed")) failed.countDown() })
|
||||
val sub = capture.subscribe("radio")
|
||||
try {
|
||||
device.input.offer(IllegalStateException("read failed"))
|
||||
assertTrue(failed.await(3, TimeUnit.SECONDS)); assertTrue(capture.closed)
|
||||
assertThrows(IllegalStateException::class.java) { sub.poll() }
|
||||
assertThrows(IllegalStateException::class.java) { capture.subscribe("new") }
|
||||
} finally { sub.close() }
|
||||
assertNull(sub.poll()); assertEquals(1, device.closes.get())
|
||||
}
|
||||
|
||||
@Test fun changedAndReleasedObserverFailuresDoNotLeakLastDevice() {
|
||||
val device = FaultyDevice(); val logs = LinkedBlockingQueue<String>(); val releases = AtomicInteger()
|
||||
val capture = MicrophoneCapture(device, { _, text -> logs.add(text) },
|
||||
released = { releases.incrementAndGet(); error("release observer failed") }, changed = { error("change observer failed") })
|
||||
val sub = capture.subscribe("radio")
|
||||
repeat(3) { sub.close() }
|
||||
assertEquals(1, device.stops.get()); assertEquals(1, device.closes.get()); assertEquals(1, releases.get())
|
||||
diagnostic(logs, "changed observer failed"); diagnostic(logs, "released observer failed")
|
||||
}
|
||||
|
||||
@Test fun interruptedCallerFinishesCleanupAndRetainsInterruptFlag() {
|
||||
val device = FaultyDevice(); val releases = AtomicInteger()
|
||||
val capture = MicrophoneCapture(device, { _, _ -> }, released = { releases.incrementAndGet() })
|
||||
val sub = capture.subscribe("radio")
|
||||
assertTrue(device.entered.await(3, TimeUnit.SECONDS))
|
||||
val result = FutureTask {
|
||||
Thread.currentThread().interrupt()
|
||||
sub.close()
|
||||
Thread.currentThread().isInterrupted
|
||||
}
|
||||
Thread(result, "interrupted-capture-owner").apply { isDaemon = true; start() }
|
||||
assertTrue(result.get(3, TimeUnit.SECONDS))
|
||||
assertEquals(1, device.closes.get()); assertEquals(1, releases.get())
|
||||
}
|
||||
|
||||
@Test fun stopAndCloseFailureStillJoinReaderAndNotifyReleaseOnce() {
|
||||
val device = FaultyDevice().apply { failStop = true; failClose = true }
|
||||
val logs = LinkedBlockingQueue<String>(); val releases = AtomicInteger()
|
||||
val capture = MicrophoneCapture(device, { _, text -> logs.add(text) }, released = { releases.incrementAndGet() })
|
||||
val sub = capture.subscribe("radio")
|
||||
assertTrue(device.entered.await(3, TimeUnit.SECONDS))
|
||||
sub.close()
|
||||
assertEquals(1, device.closes.get()); assertEquals(1, releases.get())
|
||||
diagnostic(logs, "capture stop failed"); diagnostic(logs, "capture release failed")
|
||||
}
|
||||
|
||||
@Test fun workerStartupFailureRollsBackWithoutReturningOrphanSubscription() {
|
||||
for (factoryFailure in listOf(false, true)) {
|
||||
val device = FaultyDevice(); val releases = AtomicInteger()
|
||||
val capture = MicrophoneCapture(device, { _, _ -> }, released = { releases.incrementAndGet() }, threadFactory = { task ->
|
||||
if (factoryFailure) error("thread factory denied")
|
||||
object : Thread(task) { override fun start() { error("thread start denied") } }
|
||||
})
|
||||
assertThrows(IllegalStateException::class.java) { capture.subscribe("radio") }
|
||||
assertTrue(capture.closed); assertTrue(capture.names.isEmpty())
|
||||
assertEquals(1, device.closes.get()); assertEquals(1, releases.get())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun alreadyRunningFactoryResultCannotJoinOrInterruptCaller() {
|
||||
val device = FaultyDevice()
|
||||
val capture = MicrophoneCapture(device, { _, _ -> }, threadFactory = { Thread.currentThread() })
|
||||
assertThrows(IllegalStateException::class.java) { capture.subscribe("radio") }
|
||||
assertFalse(Thread.currentThread().isInterrupted)
|
||||
assertEquals(1, device.closes.get())
|
||||
}
|
||||
|
||||
@Test fun readerCannotCloseAndJoinItself() {
|
||||
val device = FaultyDevice(); val failed = CountDownLatch(1); val logs = LinkedBlockingQueue<String>()
|
||||
val capture = MicrophoneCapture(device, { _, text -> logs.add(text); if (text.startsWith("capture reader failed")) failed.countDown() })
|
||||
val sub = capture.subscribe("radio")
|
||||
device.onRead = { sub.close() }
|
||||
try {
|
||||
device.input.offer(MicrophoneCapture.Frame(shortArrayOf(1, 2), 1, 0))
|
||||
assertTrue(failed.await(3, TimeUnit.SECONDS))
|
||||
assertTrue(capture.closed)
|
||||
diagnostic(logs, "close from reader rejected")
|
||||
} finally { device.onRead = {}; sub.close() }
|
||||
assertEquals(1, device.closes.get())
|
||||
}
|
||||
|
||||
@Test fun controlCapacityReportsOverflowAndClosedPttIsRejected() {
|
||||
val device = FaultyDevice(); val logs = LinkedBlockingQueue<String>()
|
||||
val capture = MicrophoneCapture(device, { _, text -> logs.add(text) })
|
||||
val sub = capture.subscribe("radio")
|
||||
try {
|
||||
repeat(32) { assertTrue(sub.ptt(it % 2 == 0)) }
|
||||
assertFalse(sub.ptt(true)); diagnostic(logs, "control queue full")
|
||||
} finally { sub.close() }
|
||||
assertFalse(sub.ptt(true)); assertNull(sub.poll())
|
||||
}
|
||||
|
||||
@Test fun observersExecuteOutsideCaptureMonitor() {
|
||||
val device = FaultyDevice(); val locks = LinkedBlockingQueue<Boolean>()
|
||||
lateinit var capture: MicrophoneCapture
|
||||
capture = MicrophoneCapture(device, { _, _ -> }, released = { locks.add(Thread.holdsLock(capture)) },
|
||||
changed = { locks.add(Thread.holdsLock(capture)) })
|
||||
capture.subscribe("radio").close()
|
||||
assertEquals(3, locks.size); assertTrue(locks.none { it })
|
||||
}
|
||||
|
||||
@Test fun concurrentLastClosesReleaseDeviceExactlyOnce() {
|
||||
repeat(32) {
|
||||
val device = FaultyDevice(); val releases = AtomicInteger()
|
||||
val capture = MicrophoneCapture(device, { _, _ -> }, released = { releases.incrementAndGet() })
|
||||
val one = capture.subscribe("radio"); val two = capture.subscribe("stream")
|
||||
val go = CountDownLatch(1)
|
||||
val workers = listOf(one, two).map { sub -> FutureTask { go.await(); sub.close() }.also { task ->
|
||||
Thread(task).apply { isDaemon = true; start() }
|
||||
} }
|
||||
go.countDown(); workers.forEach { it.get(3, TimeUnit.SECONDS) }
|
||||
assertEquals(1, device.closes.get()); assertEquals(1, releases.get())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user