Skip to content

SwiftMCP 1.10.3

Choose a tag to compare

@odrobnik odrobnik released this 12 Aug 19:55
· 1 commit to main since this release
5bc680d

Two delivery bugs that made a server go quiet without saying so: a session could freeze for hours behind an SSE stream nobody would ever drain, and resource-updated never reached clients on non-SSE transports. (#180, #181, #182)

🧊 A session could freeze forever behind a never-bound SSE stream (#181, fixes #180)

The route layer opens an SSE stream before the connection binds to it. If that binding never happened — the response was discarded, or the drain died first — the stream had no retention deadline, because the hub only starts that clock on disconnect or finish. So it lived forever, and a general one held the session's primary slot: every routed send after that — resource-updated, log broadcasts, keep-alive pings — reported success into a stream with no reader, and not one path logged a word about it. Observed in production as a session frozen for 10+ hours while its POSTs kept working normally.

  • SessionManager now reclaims streams still unbound one retention interval past open/resume, and removal re-selects the primary-general stream, so routing heals onto the live one.
  • The NIO drain loop closes the channel when a write fails instead of returning silently. Pipeline-level failures used to leave an ESTABLISHED connection whose stream nobody drained; closing it makes the client reconnect and lets closeFuture reconcile engine state.
  • The adapter reconciles SSE responses it discards for aborted request bodies, and register() rejects connections that are not live after attach — starting the retention clock instead of parking the stream.
  • Broadcast drops stopped being silent: broadcastResourceUpdated routes through a result-reporting path that counts them (undeliverableBroadcastCount) and logs once per session per outage, with a recovery line when delivery resumes.

📡 resource-updated now reaches non-SSE transports (#182)

The first cut of the fix above routed the notification straight at the SSE primary-general stream, which broke every transport that isn't SSE: TCPBonjourTransport shares the same SessionManager and its sessions never create SSE streams, so each subscribed TCP client silently received nothing — precisely the failure being fixed, moved one layer over. Delivery goes back through Session.sendResourceUpdated, so each transport keeps its own mechanism, and the delivery signal now comes from the throw a try? used to swallow (HTTPSSETransport.send throws when no stream can be routed, TCPBonjourTransport.send when the connection is gone; a nil weak transport is checked separately, since transport?.send on nil is a no-op rather than a throw). A session destroyed while a send was suspended no longer records an outcome, which would have re-added a stale id and counted a session that no longer exists.

🧪 Test-suite housekeeping

A double close(2) in the test client — TestTCPClient was a struct whose closeSocket() was a bare close(sock), called both explicitly and from its defer — let the kernel hand the freed descriptor number to another suite's MultiThreadedEventLoopGroup kqueue in between, and the second close destroyed it: unacceptable errno 9 Bad file descriptor in kevent(...), taking the whole test process down on a brand-new NIO thread. It reads like a transport teardown defect, but the victim is just whoever allocated a descriptor in that window; no shipped code was involved. TestTCPClient is now a final class that closes at most once, with a test asserting on the count of issued close(2) calls. With the crash cause gone, the zombie-stream test goes back to three phases that each own their transport, so a failure names which state broke.