You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: AGENTS.md
+17Lines changed: 17 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -129,6 +129,23 @@ Build system: Kotlin DSL (`build.gradle.kts`) with version catalog (`gradle/libs
129
129
130
130
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.
131
131
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
+
132
149
### TCP vs WebSocket framing
133
150
134
151
-**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.
0 commit comments