Reproduce radio lifecycle cleanup and startup callback failures

This commit is contained in:
evgeny committed 2026-10-29 13:34:23 +01:00
1 parent 45bc97c25b
commit c1fe2a3cd0
1 file changed
+264
@@ -0,0 +1,264 @@
package com.utun.chat.data
import android.content.Context
import android.content.Intent
import android.media.AudioManager
import android.media.AudioTrack
import android.os.Handler
import android.os.Looper
import com.utun.chat.ChatApplication
import java.util.concurrent.ExecutionException
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
/** Настоящие engine/session/workers: отказы запуска, независимая очистка, сохранённые владельцы и stale callbacks. */
class RadioAudioLifecycleTest {
private lateinit var context: Context
private lateinit var engine: RadioAudioEngine
private lateinit var io: ExecutorService
private val main get() = Looper.getMainLooper()
@Before fun prepare() {
assertSame(main, Looper.myLooper())
context = Context(); ChatApplication.instance = context
AudioManager.shared = AudioManager(); CallController.blocked = false
AndroidMicrophoneCapture.reset(); AudioTrack.created.clear(); AudioTrack.nextFailures = emptySet(); AudioTrack.minimumBuffer = 1920
Handler.rejectDelayed = false; Handler.rejectImmediate = false
NativeLib.radioFailures = emptySet(); NativeLib.radioCommands.clear()
LogManager.entries.clear(); LogManager.console = true
engine = RadioAudioEngine()
io = RadioAudioEngine::class.java.getDeclaredField("io").apply { isAccessible = true }.get(engine) as ExecutorService
}
@After fun release() {
context.receiverFailures = emptySet(); context.failPowerService = false; context.configProvider.failRadioSettings = false
AudioTrack.created.forEach { it.failures = emptySet() }; AudioTrack.nextFailures = emptySet(); AudioTrack.minimumBuffer = 1920
NativeLib.radioFailures = emptySet(); Handler.rejectDelayed = false; Handler.rejectImmediate = false
if (!io.isShutdown) {
engine.stop().get(5, TimeUnit.SECONDS)
main.advanceBy(10_000); drain()
io.shutdown(); assertTrue(io.awaitTermination(5, TimeUnit.SECONDS))
}
val barrier = Executors.newSingleThreadExecutor()
try { barrier.submit { MediaAudioController.awaitIdle() }.get(5, TimeUnit.SECONDS) }
finally { barrier.shutdownNow() }
// Старый код теряет receiver после частичной регистрации; отрицательный контроль не загрязняет следующий тест.
context.receivers.keys.toList().forEach { context.unregisterReceiver(it) }
assertTrue(AudioTrack.created.all { it.released })
assertTrue(AndroidMicrophoneCapture.opened.all { it.closed })
}
private fun drain() { io.submit {}.get(5, TimeUnit.SECONDS) }
private fun start(group: Long = 7, expected: Boolean = true, ack: Boolean = true) {
val results = mutableListOf<Boolean>()
engine.start(group) { assertSame(main, Looper.myLooper()); results.add(it) }
drain(); main.dispatchAll()
assertEquals("one main result for group=$group", listOf(expected), results)
if (expected && ack) { engine.onSettingsApplied(NativeLib.radioRequest, true); drain() }
}
private fun failedStop() {
try { engine.stop().get(5, TimeUnit.SECONDS); fail("cleanup must report its failure") }
catch (e: ExecutionException) { assertTrue(e.cause is Exception) }
}
private fun count(kind: String) = synchronized(NativeLib.radioCommands) { NativeLib.radioCommands.count { it.kind == kind } }
private fun diagnostic(fragment: String) = assertTrue("missing diagnostic: $fragment",
synchronized(LogManager.entries) { LogManager.entries.any { fragment in it.message && it.level in listOf("WARN", "ERROR") } })
private fun await(condition: () -> Boolean) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3)
while (!condition() && System.nanoTime() < deadline) Thread.sleep(2)
assertTrue("radio state did not settle", condition())
}
@Test fun receiverCleanupFailureDoesNotSkipCaptureOutputOrNativeStopAndCanBeRetried() {
start(); assertTrue(engine.talkBegin()); drain(); await { NativeLib.radioTransmitting() }
val track = AudioTrack.created.single()
val before = count("audio stop")
context.receiverFailures = setOf("unregister")
failedStop()
assertTrue(track.released); assertFalse(NativeLib.radioTransmitting()); assertEquals(before + 1, count("audio stop"))
assertEquals(1, context.receivers.size); assertFalse(engine.isActive()); assertNull(engine.activeGroup.value)
context.receiverFailures = emptySet(); engine.stop().get(5, TimeUnit.SECONDS)
assertTrue(context.receivers.isEmpty()); assertEquals(1, track.releaseCalls)
}
@Test fun unregistrationAfterEffectFailureIsConfirmedAbsentOnRetry() {
start(); val track = AudioTrack.created.single()
context.receiverFailures = setOf("unregister after")
failedStop(); assertTrue(context.receivers.isEmpty()); assertTrue(track.released)
context.receiverFailures = emptySet(); repeat(2) { engine.stop().get(5, TimeUnit.SECONDS) }
assertEquals(2, context.unregistrationAttempts); assertEquals(1, track.releaseCalls)
}
@Test fun registrationFailuresBeforeAndAfterEffectAlwaysReportFalseAndLeaveNoOwners() {
for (failure in listOf("register", "register after")) {
context.receiverFailures = setOf(failure)
start(expected = false)
assertFalse(engine.isActive()); assertNull(engine.activeGroup.value)
assertTrue(context.receivers.isEmpty()); assertTrue(AudioTrack.created.all { it.released })
context.receiverFailures = emptySet(); engine.stop().get(5, TimeUnit.SECONDS)
}
}
@Test fun failedPowerQueryRollsBackTheRegisteredReceiverAndPlayback() {
context.failPowerService = true
start(expected = false)
assertNull(engine.activeGroup.value); assertTrue(context.receivers.isEmpty())
assertTrue(AudioTrack.created.single().released); diagnostic("audio setup failed")
}
@Test fun failedTrackReleaseStillStopsNativeAndRetainsOnlyTheFailedTrackForRetry() {
start(); val track = AudioTrack.created.single().also { it.failures = setOf("release") }
val before = count("audio stop")
failedStop()
assertEquals(before + 1, count("audio stop")); assertTrue(context.receivers.isEmpty())
assertFalse(track.released); assertTrue(track.listeners.isEmpty())
track.failures = emptySet(); repeat(2) { engine.stop().get(5, TimeUnit.SECONDS) }
assertTrue(track.released); assertEquals(2, track.releaseCalls)
}
@Test fun releaseAfterEffectFailureDoesNotCallStopOrReadTheReleasedTrackOnRetry() {
start(); val track = AudioTrack.created.single().also { it.failures = setOf("release after") }
failedStop(); assertTrue(track.released)
track.failures = emptySet(); engine.stop().get(5, TimeUnit.SECONDS)
assertEquals(2, track.releaseCalls); assertEquals(0, track.accessAfterRelease)
}
@Test fun partialSetupAndFailedRollbackStillDeliverFailureAndBlockReplacementUntilCleanupSucceeds() {
AudioTrack.nextFailures = setOf("play", "release")
start(expected = false)
val old = AudioTrack.created.single()
assertFalse(old.released); assertFalse(engine.isActive())
AudioTrack.nextFailures = emptySet()
start(8, expected = false)
assertEquals(1, AudioTrack.created.size)
old.failures = emptySet(); engine.stop().get(5, TimeUnit.SECONDS)
start(8); assertEquals(2, AudioTrack.created.size)
}
@Test fun cleanupFailureBeforeSwitchAlwaysCompletesReadinessWithoutStartingTheNewGroup() {
start(); val old = AudioTrack.created.single()
context.receiverFailures = setOf("unregister")
start(8, expected = false)
assertTrue(old.released); assertFalse(engine.isActive()); assertNull(engine.activeGroup.value)
assertEquals(1, count("audio start"))
context.receiverFailures = emptySet(); start(8)
assertEquals(2, count("audio start"))
}
@Test fun invalidMinimumBufferNeverCreatesAnOutputAndStillCompletesReadiness() {
for (minimum in listOf(-2, 0)) {
AudioTrack.minimumBuffer = minimum; start(expected = false)
assertTrue(AudioTrack.created.isEmpty()); assertNull(engine.activeGroup.value)
diagnostic("minimum buffer")
}
}
@Test fun nativeStartupFailureClearsThePublishedActiveGroup() {
NativeLib.radioFailures = setOf("start")
start(expected = false)
assertFalse(engine.isActive()); assertNull(engine.activeGroup.value); assertTrue(AudioTrack.created.isEmpty())
}
@Test fun settingsReadFailureDoesNotPublishAReadyOrActiveRadio() {
context.configProvider.failRadioSettings = true
start(expected = false)
assertFalse(engine.isActive()); assertNull(engine.activeGroup.value); assertTrue(AudioTrack.created.isEmpty())
diagnostic("radio settings read failed")
}
@Test fun nativeStopFailureDoesNotRetainAlreadyReleasedAndroidResources() {
start(); val track = AudioTrack.created.single()
NativeLib.radioFailures = setOf("stop"); failedStop()
assertTrue(track.released); assertTrue(context.receivers.isEmpty())
NativeLib.radioFailures = emptySet(); engine.stop().get(5, TimeUnit.SECONDS)
assertEquals(1, track.releaseCalls)
}
@Test fun staleReadinessCannotOverwriteTheNewGroupAndEveryCallbackRunsOnce() {
val results = mutableListOf<Pair<Long, Boolean>>()
engine.start(7) { results.add(7L to it) }; drain()
engine.start(8) { results.add(8L to it) }; drain(); main.dispatchAll()
assertEquals(listOf(7L to false, 8L to true), results)
assertEquals(8L, engine.activeGroup.value); assertTrue(engine.isActive())
}
@Test fun readinessCallbackExceptionsAreLoggedOnMainAndDoNotLoseResourceOwnership() {
engine.start(7) { throw IllegalStateException("caller callback failed") }; drain()
try { main.dispatchAll(); fail("caller exception must propagate") } catch (e: IllegalStateException) {
assertEquals("caller callback failed", e.message)
}
diagnostic("readiness callback failed")
engine.stop().get(5, TimeUnit.SECONDS); assertTrue(AudioTrack.created.single().released)
}
@Test fun rejectedReadinessDeliveryReleasesTheNewAudioWithoutRunningCallerOnIo() {
Handler.rejectImmediate = true
val results = mutableListOf<Boolean>()
engine.start(7) { results.add(it) }; drain()
diagnostic("readiness callback rejected")
assertTrue(results.isEmpty()); assertFalse(engine.isActive()); assertNull(engine.activeGroup.value)
assertTrue(AudioTrack.created.single().released); assertTrue(context.receivers.isEmpty())
}
@Test fun staleScreenReceiverCannotChangeOrStopTheReplacementGroup() {
start(); val oldReceiver = context.receivers.keys.single()
start(8); engine.setLevelMonitoring(true); drain()
assertEquals(1, count("capture start"))
oldReceiver.onReceive(context, Intent(Intent.ACTION_SCREEN_OFF)); drain()
assertEquals(0, count("capture stop")); assertEquals(8L, engine.activeGroup.value)
}
@Test fun staleSettingsAcknowledgementCannotEnableCaptureOfANewGroup() {
start(); val old = NativeLib.radioRequest
start(8, ack = false); engine.setLevelMonitoring(true); engine.onSettingsApplied(old, true); drain()
assertEquals(0, count("capture start"))
engine.onSettingsApplied(NativeLib.radioRequest, true); drain()
assertEquals(1, count("capture start"))
}
@Test fun stopRemovesSettingsTimeoutBeforeItsExecutorCanBeTerminated() {
start(ack = false); engine.stop().get(5, TimeUnit.SECONDS)
io.shutdown(); assertTrue(io.awaitTermination(5, TimeUnit.SECONDS))
main.advanceBy(10_000)
assertTrue(context.receivers.isEmpty())
}
@Test fun rejectedSettingsWatchdogIsLoggedButAnAcknowledgementCanStillEnableCapture() {
Handler.rejectDelayed = true
start(ack = false); diagnostic("settings timeout rejected")
engine.onSettingsApplied(NativeLib.radioRequest, true); engine.setLevelMonitoring(true); drain()
assertEquals(1, count("capture start"))
}
@Test fun rejectedBackgroundTimerClosesTheAutomaticWindowAndDoesNotOpenTheMicrophone() {
context.configProvider.radioSettings = RadioAutoSettings(enabled = true)
Handler.rejectDelayed = true; start()
diagnostic("automatic timeout rejected")
assertEquals(0, count("capture start")); assertTrue(engine.isActive())
}
@Test fun rejectedBackgroundTimerPreservesManualPttInsteadOfDisablingItWithAutomation() {
context.configProvider.radioSettings = RadioAutoSettings(enabled = true)
start(); Handler.rejectDelayed = true
assertTrue(engine.talkBegin()); drain(); await { NativeLib.radioTransmitting() }
diagnostic("automatic timeout rejected")
assertEquals(RadioAutoSettings.OFF, NativeLib.radioAutoMode()); assertTrue(engine.isTalking())
engine.cancelTalk(); drain(); await { !NativeLib.radioTransmitting() }
}
@Test fun workerLifecycleCallsAreRejectedBeforeChangingThePublishedGroup() {
start()
for (action in listOf<() -> Unit>({ engine.start(8) }, { engine.stop() })) {
val failure = AtomicReference<Throwable?>()
Thread({ try { action() } catch (e: Throwable) { failure.set(e) } }, "radio-lifecycle-worker").apply { start(); join() }
assertTrue("lifecycle must reject worker: ${failure.get()}", failure.get() is IllegalStateException)
}
assertEquals(7L, engine.activeGroup.value); assertTrue(engine.isActive())
}
}