fix(aap): Cancel hung BluetoothSocket connect on timeout

BluetoothSocket.connect() is a blocking JNI call that ignores coroutine cancellation, so the previous withTimeout in AapAutoConnect only cancelled the suspending wrapper while the native thread stayed pinned. Hung threads accumulated and could trigger ANRs.

Move the timeout inside AapConnection and run the blocking connect on a daemon thread; on timeout, close the socket from the caller thread to unblock the native call (the documented Android pattern for cancelling in-flight L2CAP connects).

Also cancel appScope before delegating uncaught exceptions so coroutines have a best-effort window to release resources before the system handler terminates the process.
This commit is contained in:
darken
2026-05-03 15:39:03 +02:00
committed by Matthias Urhahn
parent 7d93e772b4
commit 07c72dc86c
6 changed files with 131 additions and 16 deletions
@@ -89,6 +89,30 @@ class CapodUncaughtExceptionHandlerTest : BaseTest() {
previousHandler.throwables shouldBe listOf(throwable)
}
@Test
fun `cancels before delegating fatal exception`() {
val mainThread = Thread.currentThread()
val events = mutableListOf<String>()
val previousHandler = object : Thread.UncaughtExceptionHandler {
override fun uncaughtException(thread: Thread, throwable: Throwable) {
events += "delegate"
}
}
val throwable = IllegalStateException("boom")
val handler = CapodUncaughtExceptionHandler(
previousHandler = previousHandler,
mainThreadProvider = { mainThread },
loopMainThread = { throw AssertionError("loopMainThread should not run") },
reportForegroundServiceTimingException = { throw AssertionError("report should not run") },
cancelBeforeDelegate = { events += "cancel" },
exit = { throw AssertionError("exitProcess($it)") },
)
handler.uncaughtException(mainThread, throwable)
events shouldBe listOf("cancel", "delegate")
}
@Test
fun `delegates loop failure after suppression`() {
val mainThread = Thread.currentThread()
@@ -5,7 +5,9 @@ import android.bluetooth.BluetoothSocket
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.engine.AapConnection
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.maps.shouldBeEmpty
@@ -13,6 +15,7 @@ import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
@@ -24,6 +27,10 @@ import testhelpers.TestTimeSource
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration.Companion.milliseconds
class AapConnectionManagerTest : BaseTest() {
@@ -89,6 +96,45 @@ class AapConnectionManagerTest : BaseTest() {
manager.allStates.value.shouldBeEmpty()
}
@Test
fun `connect timeout closes in-flight socket and allows reconnect`() = testScope.runTest {
val closeCalled = CountDownLatch(1)
val closeCalls = AtomicInteger(0)
val blockingSocket = mockk<BluetoothSocket>(relaxed = true) {
every { connect() } answers {
closeCalled.await(1, TimeUnit.SECONDS)
throw IOException("closed")
}
every { close() } answers {
closeCalls.incrementAndGet()
closeCalled.countDown()
}
}
val reconnectSocket = mockk<BluetoothSocket>(relaxed = true) {
every { outputStream } returns ByteArrayOutputStream()
every { inputStream } returns ByteArrayInputStream(byteArrayOf())
}
every { socketFactory.createSocket(any(), any()) } returnsMany listOf(blockingSocket, reconnectSocket)
val connection = AapConnection(
device = testDevice,
profile = AapDeviceProfile.forModel(PodModel.AIRPODS_PRO3),
socketFactory = socketFactory,
timeSource = timeSource,
connectTimeout = 50.milliseconds,
)
shouldThrow<TimeoutCancellationException> {
connection.connect(testScope)
}
closeCalled.await(1, TimeUnit.SECONDS) shouldBe true
closeCalls.get() shouldBe 1
connection.state.value.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED
connection.connect(testScope)
advanceUntilIdle()
}
@Test
fun `remote disconnect cleans up allStates`() = testScope.runTest {
// Empty inputStream → readLoop gets -1 immediately → DISCONNECTED