Skip to content

Streamable HTTP: Add session sweeping and close hanging streams - #1130

Open
Kehrlann wants to merge 11 commits into
mainfrom
dgarnier/fix-session-eviction
Open

Kehrlann wants to merge 11 commits into
mainfrom
dgarnier/fix-session-eviction

Conversation

@Kehrlann

@Kehrlann Kehrlann commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #1021
Fixes #1022

Context

Streamable HTTP sessions and the connections they run on had no server-side lifecycle management, and expected the client to explicitly call DELETE to remove the session.
This lead to the session map growing infinitely (see #1022)

Additionally, the KeepAliveScheduler, when enabled, added pressure by sending frequent pings when no stream existed (#1021).

Implementation

This PR introduces fixes for various streams never being closed properly, as well as a session-sweeping mechanism which empties the session map when no activity is discovered.

Changes:

KeepAliveScheduler

  • The keep-alive scheduler now terminates the GET SSE stream whenever a ping fails. The stream may later be re-established by the client.
  • The keep-alive scheduler only tries to ping when an SSE stream is available for the given session.
  • The keep-alive scheduler is now on by default, as it's the only way to reliably discover a stream has been terminated on the client side. Default interval is 30 minutes.

Session eviction

  • Sessions are now periodically evicted from the server, based on a sessionSweepInterval parameter (defaults to 30 minutes).
  • Sessions are marked "active" whenever they receive a client message through POST.
  • Active sessions, as well as sessions with a live SSE stream, are not evicted.

Session cleanup

  • On close, sessions now clean up pending responses as well as open SSE streams awaiting responses (e.g. POST -> SSE stream with an elicitation request).

Known limitations

  • A handler (e.g. tool handler) that never completes still pins the container thread blocked in doPost; the unblock depends on a write failing

@lxq19991111

Copy link
Copy Markdown

Nice to see the connection/session split made explicit — releaseTransport() vs close() is the distinction that was missing in #1021/#1022.

One semantics question while this is still in progress: I think the idle clock may not restart when the last open stream is released.

lastActivityNanos is updated when client activity happens, but the three places that do openStreams.remove(this) (close, closeGracefully, releaseTransport) leave it untouched. So the time a session spent legitimately holding a connection is counted as idle time once that connection goes away, and the reconnection window a client actually gets is max(0, idleTimeout - connectionLifetime). A stream that stayed open longer than the timeout leaves no window at all.

That seems to conflict with the intent expressed in onPingFailure, where the session is kept precisely so the client may reconnect and the idle timeout only reclaims it if the client never does.

Ran this against bf508ca — it passes as written, i.e. the session is immediately evictable the moment the connection is released:

@Test
void idleClockCountsTimeSpentHoldingAnOpenStream() throws Exception {
    var idleTimeout = Duration.ofMillis(300);
    var session = session();
    var stream = session.listeningStream(new RecordingTransport());

    // The session holds a connection for longer than the idle timeout
    Thread.sleep(400);
    assertThat(session.isIdleFor(idleTimeout)).isFalse();

    // The connection is released right now, as onPingFailure would
    stream.releaseTransport();

    // ...and the session is already considered idle for 400ms
    assertThat(session.isIdleFor(idleTimeout)).isTrue();
}

Should the idle timeout start when the last open stream is released, rather than at the last client activity? sessionHoldingAnOpenStreamIsNeverIdle uses Duration.ZERO, so it passes under either reading — a positive timeout with a stream held longer than it would pin down the intended one.

Happy to help verify whichever semantics you settle on.

* keep-alive will be scheduled.
* @return this builder instance
*/
/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the new sessionIdleTimeout javadoc landed between the existing keepAliveInterval javadoc and its method, so there are now two consecutive javadoc blocks and keepAliveInterval() is left undocumented.

@Kehrlann

Copy link
Copy Markdown
Contributor Author

@lxq19991111 there is a spec-compliant case where no SSE stream is ever established - the client only issues POST requests and the server sends application/json HTTP responses (like our MCP Stateless server does). So the existence of a stream does not indicate the liveness of the session. I think it's better to track client requests rather than streams.

Now should we indeed count disconnect as client activity? It can be a client initiated action, but in that case the client is likely signaling that it wants to disconnect, so I'm not sure it's worth keeping their session active for longer.

@lxq19991111

Copy link
Copy Markdown

That makes sense. Client-originated traffic is a better liveness signal than stream presence, especially for POST-only clients, and I agree a disconnect shouldn't by itself refresh session activity. My test shows the current behavior but doesn't establish that a fresh idle window should start on disconnect — thanks for clarifying.

Separately, while tracing the cleanup paths I noticed an asymmetry between the GET and POST paths. doGet registers the wrapper's method (listeningStream::releaseTransport), which removes it from openStreams, whereas doPost registers the raw transport's method (sessionTransport::close). That marks the transport closed and completes the AsyncContext, but it doesn't remove the McpStreamableServerSessionStream created inside responseStream() from openStreams.

This normally self-heals when the response pipeline terminates and runs stream.closeGracefully(). However, if the handler never completes, the wrapper continues to block idle sweeping. With a Mono.never() handler, session.isIdleFor(Duration.ZERO) is still false after closing the raw transport.

My inclination would be to tie the cleanup to the session stream wrapper, since it owns the openStreams registration and closing only the lower-level transport leaves the higher-level lifecycle state inconsistent. Cancelling the pipeline alone wouldn't
cover it either: .then(stream.closeGracefully()) isn't reached on cancel, so isIdleFor(Duration.ZERO) stays false after disposing the subscription. That route would need a doFinally, the way sendRequest now handles cancellation. Does that match the intended ownership here?

@Kehrlann
Kehrlann force-pushed the dgarnier/fix-session-eviction branch from bf508ca to dbde226 Compare September 16, 2026 09:48
@Kehrlann Kehrlann added area/server area/transport P1 Significant bug affecting many users, highly requested feature labels Sep 16, 2026
@Kehrlann
Kehrlann marked this pull request as ready for review September 16, 2026 09:50
@Kehrlann
Kehrlann force-pushed the dgarnier/fix-session-eviction branch from dbde226 to 6338039 Compare September 16, 2026 09:52
@lxq19991111

Copy link
Copy Markdown

I pulled the latest branch (c1cc11c) and re-ran the two session-level cases I raised earlier. The recent POST cleanup and disconnected-client changes work as expected: with a Mono.never() handler, the response stream now detaches from the session both when releaseTransport() is called and when the subscription is cancelled, and hasOpenStream() becomes false in both cases. Thanks for addressing that cleanup gap.

The remaining limitation I see is narrower than the disconnected-client case covered by the latest commits: after releaseTransport(), the stream is detached and hasOpenStream() is false, but a thread waiting in responseStream.handle(...).block() remains parked because the handler subscription itself is still active. The PR already documents the general case where a never-completing handler can keep the doPost thread blocked, so I see this as a possible follow-up rather than a blocker for this PR.

One possible approach would be to avoid the explicit wait for asynchronously completing handlers, subscribe with explicit terminal-error handling, retain the subscription in a race-safe holder, and dispose it when the existing AsyncListener reports the end of the async lifecycle. Cancellation could then reuse the cleanup already centralized in usingWhen.

One semantic point to confirm would be how this should interact with pending server-initiated requests: cancellation calls closeGracefully(), while releaseTransport() keeps the session available for a separate POST.

This would still not detect a silent peer disconnect when no further I/O occurs, especially with setTimeout(0), and it would not prevent a handler from blocking during subscription; those cases would require a separate timeout or processing-deadline policy.

Would you be open to a focused follow-up PR for this narrower limitation, with regression coverage for the Mono.never() path, synchronous-completion races, and terminal write errors?

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
- Sessions are marked active when a client makes a request
- A session with an open stream is also considered active
- Inactive sessions are removed at a regular interval

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
@Kehrlann
Kehrlann force-pushed the dgarnier/fix-session-eviction branch from a2758eb to 8035337 Compare September 17, 2026 12:44
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/server area/transport P1 Significant bug affecting many users, highly requested feature

Projects

None yet

2 participants