Skip to content

Commit 91fee8b

Browse files
jamesarichclaude
andauthored
fix(transport): close transport under the send lock (#124)
* fix(transport): close transport under the send lock Ktor's byte channels are single-writer: closing the socket while another coroutine sits inside writeFully/flush mutates one kotlinx.io segment list from two coroutines and corrupts it — Segment.compact "Check failed.", or an NPE in the TLS writeRecord path as the cio-tls-closer coroutine flushes close_notify through the same channel (KTOR-7729, open upstream). Frame writes were already serialized, but every teardown path closed the transport outside that lock, and the keepalive was a second writer racing the close. - Route all four teardown paths — disconnect(), abort(), handleFatalError, and the broker-initiated DISCONNECT — through one shutdownTransport(): take sendMutex, cancel and join the read loop and keepalive while holding it, then write any DISCONNECT and close, all in one lock acquisition. - Bound the quiesce wait at 2s so a writer wedged on a dead peer cannot stall a reconnect; past that the close proceeds and the DISCONNECT is skipped. - Guard TcpTransport.close() and WebSocketTransport.close() the same way for direct SPI users. - Run teardown NonCancellable. handleFatalError cancelled the read loop before sending its DISCONNECT and closing, so when invoked from that loop the cancellation it had just requested aborted the rest of the teardown at the next suspension point, swallowed as best-effort, leaving the socket open. TeardownRaceTest holds a write open via FakeTransport.sendGate and asserts each path waits for it, and that teardown still closes when the writer never completes. Fixes #123 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(client): bound every wait in transport teardown The quiesce wait was bounded, but two waits after it were not, so a dead peer could still hold teardown open indefinitely and block a reconnect: - The DISCONNECT write inside the lock. The same full socket buffer that blocks a publish blocks this write, and the close that frees the socket sat behind it. Abandoning the write does not reopen the race: this coroutine holds the lock, so the abandoned write and the close are sequential on one writer. - The background-job joins. Cancelling a job does not interrupt a NonCancellable section, so a read loop already inside its own teardown runs that to completion first and stacks its budget onto ours. Giving up on the join is safe — sendMutex is held for the rest of the teardown, so a job that outlives the wait cannot reach the transport's writer either way. Also reset sendsInFlight in FakeTransport.reset() so a reused instance does not carry a stale counter into later assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 23bc3ab commit 91fee8b

7 files changed

Lines changed: 512 additions & 54 deletions

File tree

AGENTS.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,23 @@ Build system: Kotlin DSL (`build.gradle.kts`) with version catalog (`gradle/libs
129129

130130
Send PINGREQ every `keepAliveSeconds * 0.75` if no other packet was sent. If no PINGRESP within `keepAliveSeconds`, treat connection as dead and trigger reconnect (if enabled) or disconnect.
131131

132+
### Single-writer discipline
133+
134+
Ktor's byte channels are single-writer: two coroutines touching one channel corrupt its kotlinx.io
135+
segment list (`Segment.compact` "Check failed.", or an NPE in the TLS `writeRecord` path — see
136+
[KTOR-7729](https://youtrack.jetbrains.com/issue/KTOR-7729), open upstream). **Closing counts as
137+
writing**`socket.close()` cancels that same channel and, under TLS, hands it to ktor's
138+
`cio-tls-closer` coroutine to flush close_notify.
139+
140+
So `sendMutex` guards teardown as well as sends. `MqttConnection.shutdownTransport` is the only
141+
path that closes the transport: it takes `sendMutex`, cancels and joins the read loop and keepalive
142+
while holding it, then writes any DISCONNECT and closes — all under one lock acquisition, in a
143+
`NonCancellable` block because the loops it cancels are usually its own caller. Both transports
144+
guard `close()` the same way for direct SPI users. Every wait is bounded (2s) so a writer wedged on
145+
a dead peer cannot stall a reconnect.
146+
147+
Anything new that writes to the transport, or closes it, has to join this discipline.
148+
132149
### TCP vs WebSocket framing
133150

134151
- **TCP** (`TcpTransport.receive()`): Parse fixed header byte → decode variable-length remaining length → read exactly that many bytes. Handle partial reads correctly — this is the trickiest part.

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **Teardown no longer races an in-flight write on the transport's byte channel.** Closing the
13+
socket while another coroutine sat inside `writeFully`/`flush` mutated one kotlinx.io segment
14+
list from two coroutines and corrupted it — surfacing as `Segment.compact` "Check failed.", or a
15+
`NullPointerException` inside ktor's TLS `writeRecord` as the `cio-tls-closer` coroutine flushed
16+
close_notify through the same channel. Ktor's byte channels are single-writer and callers must
17+
serialize (YouTrack [KTOR-7729](https://youtrack.jetbrains.com/issue/KTOR-7729), open upstream).
18+
Frame writes were already serialized, but every teardown path — `disconnect()`, `abort()`, the
19+
fatal-error handler, and a broker-initiated DISCONNECT — closed the transport outside that lock,
20+
and the keepalive was a second writer racing the close. Teardown now takes the send lock first,
21+
cancels and joins the read loop and keepalive while holding it (so the keepalive can only be
22+
cancelled parked in `delay`, never mid-write), then writes any DISCONNECT and closes, all under
23+
the one lock. `TcpTransport.close()` and `WebSocketTransport.close()` do the same for direct SPI
24+
users. The wait is bounded at 2 seconds so a writer wedged on a dead peer cannot stall a
25+
reconnect; past that the close proceeds regardless and the DISCONNECT is skipped.
26+
27+
Most visible under reconnect churn on flaky mobile networks with many concurrent publishers.
28+
Fixes [#123](https://github.com/meshtastic/MQTTastic-Client-KMP/issues/123).
29+
30+
- Fatal-error teardown could skip closing the transport. `handleFatalError` cancelled the read loop
31+
before sending its DISCONNECT and closing, so when it was invoked *from* that read loop the very
32+
cancellation it had just requested aborted the rest of the teardown at the next suspension point
33+
and the exception was swallowed as best-effort. Teardown now runs non-cancellably.
34+
1035
## [0.8.0] - 2026-07-29
1136

1237
### Added

core/src/commonMain/kotlin/org/meshtastic/mqtt/MqttConnection.kt

Lines changed: 142 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import kotlinx.coroutines.Job
2222
import kotlinx.coroutines.NonCancellable
2323
import kotlinx.coroutines.TimeoutCancellationException
2424
import kotlinx.coroutines.channels.BufferOverflow
25+
import kotlinx.coroutines.currentCoroutineContext
2526
import kotlinx.coroutines.delay
2627
import kotlinx.coroutines.flow.MutableSharedFlow
2728
import kotlinx.coroutines.flow.MutableStateFlow
@@ -36,6 +37,7 @@ import kotlinx.coroutines.sync.Semaphore
3637
import kotlinx.coroutines.sync.withLock
3738
import kotlinx.coroutines.withContext
3839
import kotlinx.coroutines.withTimeout
40+
import kotlinx.coroutines.withTimeoutOrNull
3941
import kotlinx.io.bytestring.ByteString
4042
import org.meshtastic.mqtt.packet.Auth
4143
import org.meshtastic.mqtt.packet.ConnAck
@@ -357,7 +359,7 @@ internal class MqttConnection(
357359
/** Release the socket and reset state after a failed CONNECT/CONNACK handshake. */
358360
private suspend fun abandonHandshake() {
359361
try {
360-
transport.close()
362+
shutdownTransport()
361363
} catch (
362364
@Suppress("TooGenericExceptionCaught", "SwallowedException") _: Exception,
363365
) {
@@ -421,14 +423,14 @@ internal class MqttConnection(
421423
suspend fun disconnect(reasonCode: ReasonCode = ReasonCode.SUCCESS) {
422424
log.debug(TAG) { "Disconnecting with reasonCode=$reasonCode" }
423425
failAllPendingAcks()
424-
stopBackgroundJobs()
425426

426427
try {
427428
if (transport.isConnected) {
428429
// MQTT 3.1.1: DISCONNECT has no variable header/payload — always send empty
429430
// MQTT 5.0: send with reason code and optional properties
430-
sendPacket(Disconnect(reasonCode = reasonCode))
431-
transport.close()
431+
shutdownTransport(Disconnect(reasonCode = reasonCode))
432+
} else {
433+
shutdownTransport()
432434
}
433435
} finally {
434436
resetConnectionState()
@@ -445,12 +447,9 @@ internal class MqttConnection(
445447
suspend fun abort() {
446448
log.debug(TAG) { "Aborting connection (no DISCONNECT)" }
447449
failAllPendingAcks()
448-
stopBackgroundJobs()
449450

450451
try {
451-
if (transport.isConnected) {
452-
transport.close()
453-
}
452+
shutdownTransport()
454453
} finally {
455454
resetConnectionState()
456455
}
@@ -700,21 +699,30 @@ internal class MqttConnection(
700699

701700
/** Send a packet over the transport, guarded by [sendMutex] for wire-level serialization. */
702701
private suspend fun sendPacket(packet: MqttPacket) {
703-
sendMutex.withLock {
704-
val bytes = packet.encode(version)
705-
706-
// Enforce broker's Maximum Packet Size on outbound packets (§3.2.2.3.6)
707-
serverMaximumPacketSize?.let { maxSize ->
708-
require(bytes.size <= maxSize) {
709-
"Outbound ${packet.packetType} (${bytes.size} bytes) exceeds server " +
710-
"Maximum Packet Size ($maxSize) (§3.2.2.3.6)"
711-
}
712-
}
702+
sendMutex.withLock { sendPacketLocked(packet) }
703+
}
713704

714-
log.trace(TAG) { "Sending ${packet.packetType} (${bytes.size} bytes)" }
715-
transport.send(bytes)
716-
lastSendMark = timeSource.markNow()
705+
/**
706+
* Write a packet to the transport. The caller must already hold [sendMutex].
707+
*
708+
* Only [sendPacket] and [shutdownTransport] call this: teardown sends its DISCONNECT
709+
* inside the same lock acquisition that closes the socket, so nothing can slip onto the
710+
* wire — or into the underlying byte channel — between the two.
711+
*/
712+
private suspend fun sendPacketLocked(packet: MqttPacket) {
713+
val bytes = packet.encode(version)
714+
715+
// Enforce broker's Maximum Packet Size on outbound packets (§3.2.2.3.6)
716+
serverMaximumPacketSize?.let { maxSize ->
717+
require(bytes.size <= maxSize) {
718+
"Outbound ${packet.packetType} (${bytes.size} bytes) exceeds server " +
719+
"Maximum Packet Size ($maxSize) (§3.2.2.3.6)"
720+
}
717721
}
722+
723+
log.trace(TAG) { "Sending ${packet.packetType} (${bytes.size} bytes)" }
724+
transport.send(bytes)
725+
lastSendMark = timeSource.markNow()
718726
}
719727

720728
/** Register a pending ack deferred for the given packet ID, guarded by [acksMutex]. */
@@ -922,37 +930,19 @@ internal class MqttConnection(
922930
}
923931

924932
log.error(TAG) { "Fatal error — tearing down connection" }
925-
stopBackgroundJobs()
926933
try {
927-
if (transport.isConnected) {
928-
if (version.supportsProperties) {
929-
// §4.13: Send DISCONNECT with appropriate reason code before closing
930-
sendPacket(Disconnect(reasonCode = effectiveReasonCode))
931-
}
932-
// MQTT 3.1.1: no DISCONNECT with reason code — just close the transport
933-
}
934-
} catch (
935-
@Suppress("SwallowedException") _: kotlin.coroutines.cancellation.CancellationException,
936-
) {
937-
// Scope cancelled during best-effort DISCONNECT — continue cleanup
938-
} catch (
939-
@Suppress("TooGenericExceptionCaught", "SwallowedException") _: Exception,
940-
) {
941-
// Best-effort DISCONNECT — transport may already be broken
942-
}
943-
try {
944-
transport.close()
945-
} catch (
946-
@Suppress("SwallowedException") _: kotlin.coroutines.cancellation.CancellationException,
947-
) {
948-
// Scope cancelled during best-effort close — continue cleanup
934+
// §4.13: MQTT 5.0 announces the reason before closing; 3.1.1 has no DISCONNECT
935+
// reason code, so it just closes.
936+
shutdownTransport(
937+
disconnect = if (version.supportsProperties) Disconnect(reasonCode = effectiveReasonCode) else null,
938+
)
949939
} catch (
950940
@Suppress("TooGenericExceptionCaught", "SwallowedException") _: Exception,
951941
) {
952-
// Best-effort transport close
942+
// Best-effort teardown — the transport may already be broken
953943
}
954944
// Use NonCancellable to ensure cleanup completes even when the calling job
955-
// (read loop or keepalive) was cancelled by stopBackgroundJobs() above.
945+
// (read loop or keepalive) was cancelled by shutdownTransport() above.
956946
withContext(NonCancellable) {
957947
failAllPendingAcks()
958948
val reason =
@@ -1071,8 +1061,7 @@ internal class MqttConnection(
10711061
),
10721062
)
10731063
}
1074-
stopBackgroundJobs()
1075-
transport.close()
1064+
shutdownTransport()
10761065
failAllPendingAcks()
10771066
_connectionState.value =
10781067
ConnectionState.Disconnected(
@@ -1239,11 +1228,96 @@ internal class MqttConnection(
12391228

12401229
// --- Private: Helpers ---
12411230

1242-
private fun stopBackgroundJobs() {
1243-
keepAliveJob?.cancel()
1244-
readLoopJob?.cancel()
1231+
/**
1232+
* Close the transport without ever overlapping an in-flight write.
1233+
*
1234+
* The transport's byte channel is single-writer. Ktor's is not merely undocumented on this
1235+
* point — closing a socket while another coroutine sits inside `writeFully`/`flush` mutates
1236+
* one kotlinx.io segment list from two coroutines and corrupts it, surfacing as
1237+
* `Segment.compact` "Check failed." or a NullPointerException deep inside the TLS record
1238+
* writer (YouTrack KTOR-7729, still open upstream). So teardown has to join the same
1239+
* single-writer discipline as [sendPacket] rather than run beside it.
1240+
*
1241+
* Order matters:
1242+
* 1. Take [sendMutex]. No writer is inside `transport.send` once it is held.
1243+
* 2. Cancel the read loop and keepalive *while holding it*, so the keepalive can only ever be
1244+
* cancelled parked in `delay` — never midway through a write — and join them so a cancelled
1245+
* writer cannot resume after the socket is gone.
1246+
* 3. Write [disconnect], if any, and close the transport, still under the lock.
1247+
*
1248+
* If a writer is wedged (a dead peer with a full socket buffer blocks `writeFully` until the
1249+
* TCP timeout), waiting for it forever would hang teardown and, with auto-reconnect, the
1250+
* client. So the wait is bounded by [WRITER_QUIESCE_TIMEOUT_MS]; past that the socket is closed
1251+
* regardless — which is also what unblocks the stuck writer — and the DISCONNECT is skipped,
1252+
* since writing it is exactly the unsafe act being avoided.
1253+
*
1254+
* Every other wait here is bounded for the same reason: the job joins by
1255+
* [JOB_JOIN_TIMEOUT_MS], and the DISCONNECT write itself, which the same dead peer would
1256+
* otherwise block indefinitely while holding teardown open.
1257+
*
1258+
* The body is [NonCancellable] because the read loop and keepalive both call this and it
1259+
* cancels them: on a plain context every suspension point after step 2 would abort and leak
1260+
* the socket.
1261+
*/
1262+
private suspend fun shutdownTransport(disconnect: Disconnect? = null) {
1263+
// Captured before entering NonCancellable — inside it, the current Job is the
1264+
// withContext coroutine rather than the read-loop/keepalive job we must not join.
1265+
val callerJob = currentCoroutineContext()[Job]
1266+
withContext(NonCancellable) {
1267+
val quiesced =
1268+
withTimeoutOrNull(WRITER_QUIESCE_TIMEOUT_MS) {
1269+
sendMutex.lock()
1270+
true
1271+
} != null
1272+
if (!quiesced) {
1273+
log.warn(TAG) {
1274+
"Writer still in flight after ${WRITER_QUIESCE_TIMEOUT_MS}ms — closing transport anyway"
1275+
}
1276+
}
1277+
try {
1278+
stopBackgroundJobs(callerJob)
1279+
if (disconnect != null && quiesced && transport.isConnected) {
1280+
try {
1281+
// Bounded: the same dead peer that blocks a publish blocks this write, and
1282+
// the close that follows is what frees the socket. Abandoning the write
1283+
// does not reopen the race — this coroutine holds the lock, so the
1284+
// abandoned write and the close are sequential on one writer.
1285+
withTimeoutOrNull(WRITER_QUIESCE_TIMEOUT_MS) { sendPacketLocked(disconnect) }
1286+
} catch (
1287+
@Suppress("TooGenericExceptionCaught", "SwallowedException") _: Exception,
1288+
) {
1289+
// Best-effort DISCONNECT — the transport may already be broken
1290+
}
1291+
}
1292+
transport.close()
1293+
} finally {
1294+
if (quiesced) sendMutex.unlock()
1295+
}
1296+
}
1297+
}
1298+
1299+
/**
1300+
* Cancel and join the read loop and keepalive jobs.
1301+
*
1302+
* [callerJob] is the job this teardown is running on, if any; it is cancelled but not joined,
1303+
* because the read loop and the keepalive both reach here through [handleFatalError] and a job
1304+
* cannot join itself.
1305+
*/
1306+
private suspend fun stopBackgroundJobs(callerJob: Job?) {
1307+
val jobs = listOfNotNull(keepAliveJob, readLoopJob)
12451308
keepAliveJob = null
12461309
readLoopJob = null
1310+
jobs.forEach { it.cancel() }
1311+
jobs.filter { it !== callerJob }.forEach { job ->
1312+
// Bounded, because cancelling a job does not interrupt a NonCancellable section: a
1313+
// read loop already inside its own teardown runs that to completion first, and an
1314+
// unbounded join would stack its budget onto ours and delay a reconnect. Giving up
1315+
// on the join is safe — [sendMutex] is held for the rest of the teardown, so a job
1316+
// that outlives this wait cannot reach the transport's writer either way.
1317+
if (withTimeoutOrNull(JOB_JOIN_TIMEOUT_MS) { job.join() } == null) {
1318+
log.warn(TAG) { "Background job did not finish within ${JOB_JOIN_TIMEOUT_MS}ms — closing anyway" }
1319+
}
1320+
}
12471321
}
12481322

12491323
/** Build a CONNECT packet from [config] per §3.1. */
@@ -1425,6 +1499,23 @@ internal class MqttConnection(
14251499
/** Timeout for waiting on acknowledgement packets (30 seconds). */
14261500
const val ACK_TIMEOUT_MS = 30_000L
14271501

1502+
/**
1503+
* How long teardown waits for an in-flight write to finish before closing anyway.
1504+
*
1505+
* Long enough that a normal write completes first — that is what keeps the socket close
1506+
* off the byte channel's writer — and short enough that a writer wedged on a dead peer
1507+
* cannot stall a reconnect. See [shutdownTransport].
1508+
*/
1509+
const val WRITER_QUIESCE_TIMEOUT_MS = 2_000L
1510+
1511+
/**
1512+
* How long teardown waits for a cancelled background job to finish.
1513+
*
1514+
* Cancellation does not interrupt a `NonCancellable` section, so a job already inside its
1515+
* own teardown finishes that first; without a bound its budget would stack onto ours.
1516+
*/
1517+
const val JOB_JOIN_TIMEOUT_MS = 2_000L
1518+
14281519
/** Keepalive factor: send PINGREQ at 75% of keep alive interval. */
14291520
const val KEEPALIVE_FACTOR = 750 // keepAliveSeconds * 750 = milliseconds at 75%
14301521

0 commit comments

Comments
 (0)