@@ -27,10 +27,13 @@ import kotlinx.coroutines.CoroutineScope
2727import kotlinx.coroutines.Job
2828import kotlinx.coroutines.NonCancellable
2929import kotlinx.coroutines.SupervisorJob
30+ import kotlinx.coroutines.TimeoutCancellationException
3031import kotlinx.coroutines.cancel
3132import kotlinx.coroutines.channels.BufferOverflow
3233import kotlinx.coroutines.channels.Channel
34+ import kotlinx.coroutines.currentCoroutineContext
3335import kotlinx.coroutines.delay
36+ import kotlinx.coroutines.ensureActive
3437import kotlinx.coroutines.flow.Flow
3538import kotlinx.coroutines.flow.MutableSharedFlow
3639import kotlinx.coroutines.flow.MutableStateFlow
@@ -50,6 +53,8 @@ import kotlinx.coroutines.launch
5053import kotlinx.coroutines.sync.Mutex
5154import kotlinx.coroutines.sync.withLock
5255import kotlinx.coroutines.withContext
56+ import kotlinx.coroutines.withTimeout
57+ import kotlinx.coroutines.withTimeoutOrNull
5358import okio.ByteString.Companion.toByteString
5459import org.koin.core.annotation.Named
5560import 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
0 commit comments