mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
fix(aap): Bound handshake with watchdog and escalate reconnect backoff
This commit is contained in:
@@ -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
|
||||
|
||||
+36
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user