mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user