fix(aap): Bound handshake with watchdog and escalate reconnect backoff

This commit is contained in:
darken
2026-06-14 21:28:42 +02:00
committed by Matthias Urhahn
parent e406fd4cc1
commit fd4a43de96
7 changed files with 337 additions and 81 deletions
@@ -5,6 +5,7 @@ import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapDisconnectEvent
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
@@ -22,7 +23,9 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
@@ -40,7 +43,7 @@ class AapAutoConnectTest : BaseTest() {
private lateinit var profilesFlow: MutableStateFlow<List<DeviceProfile>>
private lateinit var connectedDevicesFlow: MutableStateFlow<List<BluetoothDevice2>>
private lateinit var disconnectEventsFlow: MutableSharedFlow<String>
private lateinit var disconnectEventsFlow: MutableSharedFlow<AapDisconnectEvent>
private lateinit var allStatesFlow: MutableStateFlow<Map<String, AapPodState>>
private val testAddress = "AA:BB:CC:DD:EE:FF"
@@ -351,7 +354,7 @@ class AapAutoConnectTest : BaseTest() {
// Clear profiles, then disconnect
profilesFlow.value = emptyList()
allStatesFlow.value = emptyMap()
disconnectEventsFlow.tryEmit(testAddress)
disconnectEventsFlow.tryEmit(AapDisconnectEvent(testAddress, wasEverReady = true))
advanceUntilIdle()
// The reconnect loop should not call connect since profile is gone
@@ -369,7 +372,7 @@ class AapAutoConnectTest : BaseTest() {
// Remove bonded device, then disconnect
every { bluetoothManager.bondedDevices() } returns flowOf(emptySet())
allStatesFlow.value = emptyMap()
disconnectEventsFlow.tryEmit(testAddress)
disconnectEventsFlow.tryEmit(AapDisconnectEvent(testAddress, wasEverReady = true))
advanceUntilIdle()
// Reconnect should not call connect since not bonded
@@ -387,7 +390,7 @@ class AapAutoConnectTest : BaseTest() {
// Remove classic BT connection, then disconnect
connectedDevicesFlow.value = emptyList()
allStatesFlow.value = emptyMap()
disconnectEventsFlow.tryEmit(testAddress)
disconnectEventsFlow.tryEmit(AapDisconnectEvent(testAddress, wasEverReady = true))
advanceUntilIdle()
// Reconnect should not call connect since not classically connected
@@ -405,7 +408,7 @@ class AapAutoConnectTest : BaseTest() {
// Remove from BLE scans, then disconnect
every { blePodMonitor.devices } returns flowOf(emptyList())
allStatesFlow.value = emptyMap()
disconnectEventsFlow.tryEmit(testAddress)
disconnectEventsFlow.tryEmit(AapDisconnectEvent(testAddress, wasEverReady = true))
advanceUntilIdle()
// Reconnect should not call connect since not visible in BLE
@@ -422,7 +425,7 @@ class AapAutoConnectTest : BaseTest() {
// Keep as READY, emit disconnect event
// allStatesFlow still shows READY → reconnect should skip
disconnectEventsFlow.tryEmit(testAddress)
disconnectEventsFlow.tryEmit(AapDisconnectEvent(testAddress, wasEverReady = true))
advanceUntilIdle()
// Should not attempt connect — already connected
@@ -430,6 +433,44 @@ class AapAutoConnectTest : BaseTest() {
job.cancel()
}
@Test
fun `was-ready disconnect reconnects without extra cooldown`() = runTest(testDispatcher) {
val autoConnect = createAutoConnect()
val job = setupForReconnect(autoConnect)
advanceUntilIdle()
allStatesFlow.value = emptyMap()
// A session that reached READY drops: only the normal first retry delay (3s), no backoff cooldown.
disconnectEventsFlow.tryEmit(AapDisconnectEvent(testAddress, wasEverReady = true))
advanceTimeBy(3_100)
runCurrent()
coVerify(exactly = 1) { aapManager.connect(testAddress, any(), any()) }
job.cancel()
}
@Test
fun `never-ready disconnect backs off before reconnecting`() = runTest(testDispatcher) {
val autoConnect = createAutoConnect()
val job = setupForReconnect(autoConnect)
advanceUntilIdle()
allStatesFlow.value = emptyMap()
// A session that never reached READY (failed handshake): first failure adds a 5s cooldown
// on top of the 3s retry delay, so nothing should connect within the 3.1s a was-ready drop would.
disconnectEventsFlow.tryEmit(AapDisconnectEvent(testAddress, wasEverReady = false))
advanceTimeBy(3_100)
runCurrent()
coVerify(exactly = 0) { aapManager.connect(testAddress, any(), any()) }
// After the 5s cooldown + 3s retry delay elapse, the reconnect fires.
advanceUntilIdle()
coVerify(exactly = 1) { aapManager.connect(testAddress, any(), any()) }
job.cancel()
}
}
@Nested
@@ -12,7 +12,9 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.maps.shouldBeEmpty
import io.kotest.matchers.shouldBe
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.TimeoutCancellationException
@@ -27,6 +29,7 @@ import testhelpers.TestTimeSource
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
@@ -135,6 +138,53 @@ class AapConnectionManagerTest : BaseTest() {
advanceUntilIdle()
}
@Test
fun `handshake timeout disconnects a silent session`() = testScope.runTest {
// Socket connects and the handshake is sent, but the peer never replies: read() blocks
// forever. The handshake watchdog must time out and tear the session down.
val readBlocked = CountDownLatch(1)
val closeCalls = AtomicInteger(0)
val blockingInput = object : InputStream() {
override fun read(): Int {
readBlocked.await(2, TimeUnit.SECONDS)
return -1
}
override fun read(b: ByteArray): Int {
readBlocked.await(2, TimeUnit.SECONDS)
return -1
}
}
val silentSocket = mockk<BluetoothSocket>(relaxed = true) {
every { connect() } just Runs
every { outputStream } returns ByteArrayOutputStream()
every { inputStream } returns blockingInput
every { close() } answers {
closeCalls.incrementAndGet()
readBlocked.countDown()
}
}
every { socketFactory.createSocket(any(), any()) } returns silentSocket
val connection = AapConnection(
device = testDevice,
profile = AapDeviceProfile.forModel(PodModel.AIRPODS_PRO3),
socketFactory = socketFactory,
timeSource = timeSource,
connectTimeout = 50.milliseconds,
handshakeTimeout = 100.milliseconds,
)
connection.connect(testScope)
// Fire the 100ms handshake watchdog.
advanceUntilIdle()
// disconnect() runs engine.reset() before closing the socket, so awaiting the close latch
// guarantees the state has already flipped to DISCONNECTED.
readBlocked.await(2, TimeUnit.SECONDS) shouldBe true
connection.state.value.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED
closeCalls.get() shouldBe 1
}
@Test
fun `remote disconnect cleans up allStates`() = testScope.runTest {
// Empty inputStream → readLoop gets -1 immediately → DISCONNECTED
@@ -114,6 +114,38 @@ class AapSessionEngineTest : BaseTest() {
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED
}
@Test
fun `wasEverReady is false until READY then true`() {
val engine = createEngine()
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.wasEverReady shouldBe false
engine.onHandshakeSent()
engine.wasEverReady shouldBe false
// First non-CONTROL message during HANDSHAKING → READY
engine.processMessage(dummyMessage(commandType = 0x0002))
engine.wasEverReady shouldBe true
}
@Test
fun `wasEverReady survives reset`() {
val engine = createEngine()
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.onHandshakeSent()
engine.processMessage(dummyMessage(commandType = 0x0002))
engine.wasEverReady shouldBe true
engine.reset()
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED
// Consumers read this at disconnect time — reset must not clear it.
engine.wasEverReady shouldBe true
}
@Test
fun `reset is idempotent`() {
val engine = createEngine()
@@ -220,12 +252,13 @@ class AapSessionEngineTest : BaseTest() {
}
@Test
fun `Connect Response with non-zero status does not store features`() {
fun `Connect Response with non-zero status tears down the session`() {
val engine = createEngine()
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.onHandshakeSent()
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.HANDSHAKING
val packet = AapPacket.ConnectResponse(
raw = ByteArray(18),
@@ -237,9 +270,9 @@ class AapSessionEngineTest : BaseTest() {
)
engine.processConnectResponse(packet)
engine.state.value.connectResponseStatus shouldBe 0x0001
// Hard protocol rejection: fast-fail to DISCONNECTED instead of waiting out the watchdog.
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED
engine.state.value.negotiatedFeatures.shouldBeNull()
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.HANDSHAKING
}
}