Skip to content

Stateful StreamableHttpServerTransport leaks the standalone GET SSE stream on session close #922

Description

@NexusIDS

Summary

Closing a stateful StreamableHttpServerTransport session (via DELETE, or any other path that calls close()) never cancels the coroutine handling the standalone GET SSE stream. The connection is left permanently open on the server side. Every completed session leaks one socket.

This is distinct from #786 / #872, which fixed a session-cleanup leak in the stateless transport (mcpStatelessStreamableHttp). The bug described here is in the stateful transport (mcpStreamableHttp) and is still present after that fix.

Also related to #715 / #716: that fix made the STANDALONE_SSE_STREAM_ID mapping cleanup synchronous once the GET-handling coroutine is cancelled (e.g. on client disconnect). It's already present in 0.14.0 and 0.15.0. It doesn't address the issue here, which is that close() never triggers that cancellation in the first place.

Root cause

StreamableHttpServerTransport.handleGetRequest() keeps the standalone GET stream open by suspending on awaitCancellation():

try {
    awaitCancellation()
} finally {
    streamsMapping.remove(STANDALONE_SSE_STREAM_ID, newContext)
}

close() (invoked from handleDeleteRequest) only closes the ServerSSESession:

streamsMapping.values.forEach {
    try { it.session?.close() } catch (_: Exception) {}
}

ServerSSESession.close() (DefaultServerSSESession in ktor-server-sse) does output.flushAndClose() — it closes the write side of the response body, but it does not cancel the Job that's suspended in awaitCancellation(). That coroutine, and the ApplicationCall/socket behind it, never complete and are never released.

Reproduction

Run N sequential sessions against a stateful mcpStreamableHttp server (initializeGET → any request → DELETE, each completing successfully), and count open sockets on the server process between rounds:

ls -la /proc/<server-pid>/fd | grep -c "socket:\["

Result (measured directly against the server process, no reverse proxy involved):

baseline:            12 sockets
after 10 sessions:   22 sockets   (+10)
after 10 more:       32 sockets   (+10)

Exactly one leaked socket per session, reproducible on demand. Confirmed on 0.14.0 and 0.15.0 (ktor 3.5.1, JVM 21) — handleGetRequest() is unchanged between those versions.

Impact

In isolation this is a slow resource leak that eventually exhausts the process's file descriptor limit. It becomes an acute, user-visible problem behind a reverse proxy that pools/reuses backend connections (observed with Caddy, default keepalive): once the GET response body reaches EOF (from flushAndClose()), the proxy considers that connection idle and reusable, even though the server-side coroutine handling it never returned. The proxy then hands that connection to an unrelated subsequent request, which is silently dropped — no response, no error, no timeout on the server side, until the client's own timeout fires.

Reproduced this reliably in production: the first session in a freshly started process always succeeds; every session after that hangs on the first request routed onto a reused, leaked connection.

Expected behavior

Tearing down a session should cancel the Job backing the standalone GET stream's request-handling coroutine, not just close the ServerSSESession's output channel, so the HTTP exchange actually completes from the engine's perspective and the connection is genuinely released.

Suggested fix direction

Track the Job for the GET-handling call (e.g. call.coroutineContext.job, captured when handleGetRequest starts) alongside the existing SessionContext, and cancel it explicitly in close() in addition to calling session.close().

Environment

  • kotlin-sdk: 0.14.0, 0.15.0
  • ktor: 3.5.1
  • JVM: 21 (Corretto)
  • Transport: mcpStreamableHttp (stateful, enableJsonResponse = true)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions