The TCP+Bonjour transport leaked one file descriptor per inbound connection, and several teardown paths around it were unsound. This release fixes the full audit from #171 (PR #172).
🩹 The leak
cleanupConnection dropped the NWConnection without ever calling cancel() — and Network.framework only releases the underlying socket (and the self-retained connection object) on cancel. Every client connection stranded one descriptor for the process lifetime; a long-running daemon accumulated ~30/h until its descriptor table was full and it wedged, still looking healthy from the outside.
Teardown now cancels inside the state actor, so the three racing cleanup paths (receive error, peer EOF, .failed) cannot double-act, and per-connection dispatch tasks are tracked and reaped with the connection — a tool body looping on Task.isCancelled stops when its client is gone instead of holding its resources forever.
⚠️ Behaviour changes
TCPBonjourTransport.run() now throws on unrecoverable listener failure. NWListener fails asynchronously, so a bind conflict arrives after start() already returned success; previously it was logged and swallowed, run() parked forever, and the host served nothing while believing it was up. Supervised daemons now exit non-zero and get restarted. Embedders that want to survive a dead secondary transport can catch the throw.
TransportError gains .resourceExhausted, which breaks exhaustive switches over it. It is thrown when the process runs out of file descriptors: EMFILE/ENFILE is escalated where it surfaces, and a watchdog samples the descriptor table (every 30 s, getrlimit + fcntl scan) because a listener under EMFILE stays .ready and reports nothing while accepting nothing. The transport fails terminally at ≥ 80 % of the real RLIMIT_NOFILE; with limits beyond the 65,536-slot scan window the watchdog is inert.
notifications/cancelled now actually cancels. It was an explicit no-op. The identified in-flight request is cancelled cooperatively (its task sees ordinary Swift cancellation) and its response is suppressed per spec. This works on every transport with a session identity — TCP, and HTTP with Mcp-Session-Id. Cancellations that race ahead of their request, or arrive coalesced in the same TCP read, are handled correctly. New public API: Session.cancelInFlightRequest(id:).
Disconnect semantics are now split by intent. An abortive close (RST, receive error) cancels in-flight tool handlers immediately. A clean EOF is a half-close — the peer may still be reading — so handlers get a bounded drain window (30 s) to finish and flush their replies before the socket is torn down; whatever outlives it is cancelled.
TCP keepalive is enabled. Session expiry never fires for the TCP transport (it is driven by SSE streams, which TCP does not create), so a peer that vanished without FIN/RST used to pin its descriptor and session forever. A dead peer now surfaces as a receive error within ~2 minutes and is cleaned up.
🐛 Also fixed
- The final unterminated line of a connection was silently dropped on every close, and lines spanning receive callbacks could interleave: line assembly now happens synchronously on the connection's serial queue instead of racing chunk-tasks into an actor.
- A connection accepted around
stop()could be registered after the shutdown sweep and live on a stopped transport; registration is now refused (and the connection cancelled). HTTPSSETransport: a failed bind leaked the adapter's wholeEventLoopGroup(one per attempt); a repeatedstart()stranded the live listener unreachable while the new bind failedEADDRINUSEagainst it.start()is now keyed off a successful bind and idempotent, and the keep-alive timer is no longer overwritten while resumed.- A transport released without
stop()orphaned a listener that kept accepting (and leaking) connections;deinitnow sweeps the state. - Two
lazymembers could be double-created by concurrent first connections; they are forced before concurrent touch.
Verifying
The regression tests count descriptors, never TCP states — a leaked fd shows CLOSE_WAIT after a graceful FIN, CLOSED after an RST, and ages between them, so a state-based assertion passes while leaking. Against a live host:
lsof -p <pid> | awk '$4 ~ /^[0-9]+/' | wc -l # before, then after N connections — must match