Skip to content

Commit e015f09

Browse files
jamesarichclaude
andcommitted
fix(service): unwedge the inbound pipeline behind stale-Connected zombies
A handler that suspends indefinitely inside runWhileSessionActive holds sessionOperationMutex (the entire inbound packet pipeline) and an admitted session lease, which teardown's uncancellable drain awaits. The result is the field-reported stale connection: the app shows Connected, nodes stop updating, disconnect never completes, and only a force-stop recovers. Observed in production as multi-hour "receive queue at capacity 8192" sessions on 2.8.0. - Bound each session handler with a 2-minute timeout; cancellation releases the pipeline and the lease, and the drop is logged at error level so field occurrences reach RUM error tracking. - Report (every 15s, error level) when transport teardown is blocked waiting for admitted operations to drain, instead of waiting silently forever. - Stamp lastDataReceivedMillis only for frames actually admitted to the receive queue, so a wedged consumer reads as silence and trips liveness recovery instead of keeping it satisfied while every frame is dropped. - Guard the sole transport->app connection-state bridge per-emission so one throw cannot permanently freeze the app-level state machine. Fixes #6491 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7710220 commit e015f09

3 files changed

Lines changed: 190 additions & 4 deletions

File tree

core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import org.meshtastic.core.common.di.ServiceScope
3333
import org.meshtastic.core.common.util.handledLaunch
3434
import org.meshtastic.core.common.util.nowMillis
3535
import org.meshtastic.core.common.util.nowSeconds
36+
import org.meshtastic.core.common.util.safeCatching
3637
import org.meshtastic.core.common.util.safeCatchingAll
3738
import org.meshtastic.core.model.ConnectionState
3839
import org.meshtastic.core.model.DeviceType
@@ -136,7 +137,14 @@ class MeshConnectionManagerImpl(
136137
// Bridge transport-level state into the canonical app-level state.
137138
// This is the ONLY consumer of RadioInterfaceService.connectionState — it applies
138139
// light-sleep policy and handshake awareness before writing to ServiceRepository.
139-
radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope)
140+
// Guarded per-emission: one uncaught throw here would kill the sole bridge collector and
141+
// permanently freeze the app-level state (a stuck-"Connected" UI no transport event can fix).
142+
radioInterfaceService.connectionState
143+
.onEach { state ->
144+
safeCatching { onRadioConnectionState(state) }
145+
.onFailure { Logger.e(it) { "Connection state bridge failed for $state; collector kept alive" } }
146+
}
147+
.launchIn(scope)
140148

141149
// Ensure notification title and content stay in sync with state changes
142150
serviceRepository.connectionState.onEach { updateStatusNotification() }.launchIn(scope)

core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,13 @@ import kotlinx.coroutines.CoroutineScope
2727
import kotlinx.coroutines.Job
2828
import kotlinx.coroutines.NonCancellable
2929
import kotlinx.coroutines.SupervisorJob
30+
import kotlinx.coroutines.TimeoutCancellationException
3031
import kotlinx.coroutines.cancel
3132
import kotlinx.coroutines.channels.BufferOverflow
3233
import kotlinx.coroutines.channels.Channel
34+
import kotlinx.coroutines.currentCoroutineContext
3335
import kotlinx.coroutines.delay
36+
import kotlinx.coroutines.ensureActive
3437
import kotlinx.coroutines.flow.Flow
3538
import kotlinx.coroutines.flow.MutableSharedFlow
3639
import kotlinx.coroutines.flow.MutableStateFlow
@@ -50,6 +53,8 @@ import kotlinx.coroutines.launch
5053
import kotlinx.coroutines.sync.Mutex
5154
import kotlinx.coroutines.sync.withLock
5255
import kotlinx.coroutines.withContext
56+
import kotlinx.coroutines.withTimeout
57+
import kotlinx.coroutines.withTimeoutOrNull
5358
import okio.ByteString.Companion.toByteString
5459
import org.koin.core.annotation.Named
5560
import org.koin.core.annotation.Single
@@ -271,7 +276,23 @@ class SharedRadioInterfaceService(
271276
}
272277

273278
override suspend fun runWhileSessionActive(session: RadioSessionContext, block: suspend () -> Unit): Boolean =
274-
sessionOperationMutex.withLock { runWithSessionLease(session) { block() } }
279+
sessionOperationMutex.withLock {
280+
runWithSessionLease(session) {
281+
// Bound the handler: it holds sessionOperationMutex (the whole inbound pipeline) and an admitted
282+
// lease (which teardown's drain awaits), so an indefinite suspension here is a total wedge, not a
283+
// slow packet. Cancelling the block releases both. Only OUR timeout is swallowed — ensureActive()
284+
// rethrows if the surrounding scope was cancelled concurrently.
285+
try {
286+
withTimeout(SESSION_HANDLER_TIMEOUT_MILLIS) { block() }
287+
} catch (timeout: TimeoutCancellationException) {
288+
currentCoroutineContext().ensureActive()
289+
Logger.e(timeout) {
290+
"Session handler exceeded ${SESSION_HANDLER_TIMEOUT_MILLIS}ms and was cancelled; " +
291+
"dropping its packet to keep the receive pipeline alive"
292+
}
293+
}
294+
}
295+
}
275296

276297
/** Runs a callback only while [session] still owns admission, atomically with session teardown. */
277298
private inline fun runIfTransportSessionActive(session: RadioTransportSession, block: () -> Unit): Boolean =
@@ -299,7 +320,21 @@ class SharedRadioInterfaceService(
299320
sessionDrainWaiter ?: CompletableDeferred<Unit>().also { sessionDrainWaiter = it }
300321
}
301322
}
302-
drainWaiter?.await()
323+
if (drainWaiter != null) {
324+
// The drain must complete before a replacement generation is admitted (DB-atomicity contract), so we
325+
// keep waiting — but never silently. A lease stuck past the handler timeout means a handler ignored
326+
// cancellation; these error-level reports are the observability surface for that wedge (this wait
327+
// previously blocked disconnect()/restart forever with no telemetry at all).
328+
var waitedMillis = 0L
329+
while (withTimeoutOrNull(DRAIN_WAIT_LOG_INTERVAL_MILLIS) { drainWaiter.await() } == null) {
330+
waitedMillis += DRAIN_WAIT_LOG_INTERVAL_MILLIS
331+
val outstanding = synchronized(sessionCallbackLock) { admittedSessionOperations }
332+
Logger.e {
333+
"Transport teardown blocked ${waitedMillis}ms waiting for $outstanding admitted session " +
334+
"operation(s) to release (generation=${session.generation})"
335+
}
336+
}
337+
}
303338
synchronized(sessionCallbackLock) {
304339
if (activeTransportSession === session) {
305340
check(admittedSessionOperations == 0) { "Session revoked before admitted operations drained" }
@@ -432,6 +467,18 @@ class SharedRadioInterfaceService(
432467
* flaky GATT connection. Serial and TCP typically flush well under this window.
433468
*/
434469
private const val POLITE_DISCONNECT_DRAIN_MS = 500L
470+
471+
/**
472+
* Ceiling on a single [runWhileSessionActive] handler. The block holds [sessionOperationMutex] — the whole
473+
* inbound pipeline — so a handler that suspends indefinitely wedges packet processing, fills [_receivedData],
474+
* and deadlocks teardown's lease drain (a Connected-looking zombie only a force-stop clears; observed in the
475+
* field as multi-hour "receive queue at capacity" sessions). 2 minutes is far above any legitimate handler
476+
* (large-mesh config DB installs run seconds) while still bounding the wedge.
477+
*/
478+
private const val SESSION_HANDLER_TIMEOUT_MILLIS = 2 * 60 * 1000L
479+
480+
/** How often [revokeTransportSession] reports a lease drain that has not completed. */
481+
private const val DRAIN_WAIT_LOG_INTERVAL_MILLIS = 15 * 1000L
435482
}
436483

437484
private val initLock = Mutex()
@@ -979,7 +1026,6 @@ class SharedRadioInterfaceService(
9791026
@Suppress("TooGenericExceptionCaught")
9801027
private fun enqueueReceivedData(bytes: ByteArray, session: RadioTransportSession) {
9811028
try {
982-
lastDataReceivedMillis = now()
9831029
// trySend synchronously onto the Channel so packet order matches arrival order. The
9841030
// previous `launch { emit() }` pattern dispatched each packet onto a fresh coroutine,
9851031
// letting the scheduler reorder them — which broke the firmware config handshake
@@ -993,6 +1039,12 @@ class SharedRadioInterfaceService(
9931039
}
9941040
val frame = ReceivedRadioFrame(payload = bytes.toByteString(), session = session.context)
9951041
val result = _receivedData.trySend(frame)
1042+
if (result.isSuccess) {
1043+
// Stamp liveness only for frames actually admitted to the queue. Stamping on arrival kept
1044+
// checkLiveness() satisfied while a wedged consumer dropped every frame — a Connected-looking zombie
1045+
// the watchdog existed to catch. A full queue now reads as silence and trips liveness recovery.
1046+
lastDataReceivedMillis = now()
1047+
}
9961048
if (result.isFailure) {
9971049
// Rate-limited on purpose: drops only happen under sustained inbound traffic, and Kermit forwards to
9981050
// Datadog/Crashlytics, so logging every drop would turn a bounded memory problem into unbounded

core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,132 @@ class SharedRadioInterfaceServiceLivenessTest {
820820
}
821821
}
822822

823+
/**
824+
* Regression for the field wedge behind "app shows Connected but the node stops updating": frames dropped by a full
825+
* receive queue must NOT feed the liveness timer. Before the fix, [SharedRadioInterfaceService] stamped
826+
* `lastDataReceivedMillis` on arrival (even for dropped frames), so a wedged consumer kept liveness satisfied
827+
* forever while every frame was discarded.
828+
*/
829+
@Test
830+
fun `frames dropped by a full receive queue do not reset the liveness timer`() = runTest(testDispatcher) {
831+
clock = 0L
832+
val service = createConnectedService("xAA:BB:CC:DD:EE:FF")
833+
try {
834+
// No collector attached: fill the channel to capacity at t=0. All of these are admitted
835+
// and stamp liveness at 0.
836+
val payload = byteArrayOf(1)
837+
repeat(SharedRadioInterfaceService.RECEIVE_QUEUE_CAPACITY) { service.handleFromRadio(payload) }
838+
839+
// A frame arriving at t=30s is DROPPED (queue full). It must not count as liveness data.
840+
clock = 30_000L
841+
service.handleFromRadio(payload)
842+
843+
// At t=65s the silence is 65s if the drop was correctly ignored (fires), but only 35s if
844+
// the drop stamped the timer (must not happen).
845+
clock = 65_000L
846+
service.checkLiveness()
847+
testDispatcher.scheduler.runCurrent()
848+
advanceTimeBy(1_000L)
849+
850+
assertTrue(
851+
createdTransports.first().closeCalled,
852+
"Liveness must fire on queue-full silence — dropped frames must not feed the timer",
853+
)
854+
} finally {
855+
service.disconnect()
856+
advanceTimeBy(1_000L)
857+
}
858+
}
859+
860+
// ─── Session handler timeout: the pipeline must not wedge forever ───────────────────────────
861+
862+
/**
863+
* Regression for the 2.8.0 stale-connection wedge: a handler that suspends indefinitely inside
864+
* [SharedRadioInterfaceService.runWhileSessionActive] holds the session-operation lane (the whole inbound
865+
* pipeline). It must be cancelled at the handler timeout so queued work behind it can run.
866+
*/
867+
@Test
868+
fun `wedged session handler is cancelled at the timeout and the pipeline continues`() = runTest(testDispatcher) {
869+
clock = 0L
870+
val service = createConnectedService("xAA:BB:CC:DD:EE:FF")
871+
val session = requireNotNull(service.activeSession.value)
872+
val wedgeStarted = CompletableDeferred<Unit>()
873+
val neverReleased = CompletableDeferred<Unit>()
874+
var wedgeRanToCompletion = false
875+
876+
val wedged = launch {
877+
service.runWhileSessionActive(session) {
878+
wedgeStarted.complete(Unit)
879+
neverReleased.await() // simulates a handler stuck on an unbounded suspension
880+
wedgeRanToCompletion = true
881+
}
882+
}
883+
wedgeStarted.await()
884+
val nextStarted = CompletableDeferred<Unit>()
885+
val next = launch { service.runWhileSessionActive(session) { nextStarted.complete(Unit) } }
886+
try {
887+
testDispatcher.scheduler.runCurrent()
888+
assertFalse(nextStarted.isCompleted, "ordered work is serialized behind the wedged handler")
889+
890+
// Cross the 2-minute handler timeout: the wedged block is cancelled, releasing the lane.
891+
advanceTimeBy(121_000L)
892+
wedged.join()
893+
next.join()
894+
895+
assertFalse(wedgeRanToCompletion, "the wedged handler must have been cancelled, not completed")
896+
assertTrue(nextStarted.isCompleted, "the handler timeout must release the pipeline for queued work")
897+
} finally {
898+
neverReleased.complete(Unit)
899+
wedged.cancel()
900+
next.cancel()
901+
service.disconnect()
902+
advanceTimeBy(1_000L)
903+
}
904+
}
905+
906+
/**
907+
* The user-facing half of the same wedge: disconnect() drains admitted leases before teardown, so a handler stuck
908+
* forever previously made disconnect unreachable (only a force-stop recovered). The handler timeout must bound that
909+
* wait.
910+
*/
911+
@Test
912+
fun `disconnect completes after a wedged handler is timed out`() = runTest(testDispatcher) {
913+
clock = 0L
914+
val service = createConnectedService("xAA:BB:CC:DD:EE:FF")
915+
val session = requireNotNull(service.activeSession.value)
916+
val wedgeStarted = CompletableDeferred<Unit>()
917+
val neverReleased = CompletableDeferred<Unit>()
918+
919+
val wedged = launch {
920+
service.runWhileSessionActive(session) {
921+
wedgeStarted.complete(Unit)
922+
neverReleased.await()
923+
}
924+
}
925+
wedgeStarted.await()
926+
927+
val disconnectJob = launch { service.disconnect() }
928+
try {
929+
testDispatcher.scheduler.runCurrent()
930+
assertFalse(disconnectJob.isCompleted, "disconnect must wait while the lease is admitted")
931+
932+
// Cross the handler timeout (cancels the wedged block, draining the lease) plus the
933+
// polite-disconnect drain window inside stopTransportLocked.
934+
advanceTimeBy(121_000L)
935+
testDispatcher.scheduler.runCurrent()
936+
advanceTimeBy(1_000L)
937+
disconnectJob.join()
938+
wedged.join()
939+
940+
assertNull(service.activeSession.value, "teardown must complete once the wedged lease is released")
941+
} finally {
942+
neverReleased.complete(Unit)
943+
wedged.cancel()
944+
service.disconnect()
945+
advanceTimeBy(1_000L)
946+
}
947+
}
948+
823949
@Test
824950
fun `USB permission denial emits error and permanent disconnected state`() = runTest(testDispatcher) {
825951
clock = 0L

0 commit comments

Comments
 (0)