Repository navigation
Fix legacy MCP session request isolation - #7337
Conversation
Aias00
left a comment
There was a problem hiding this comment.
Approving. Two independent pieces of per-session state were being shared across concurrent requests — the ServerWebExchange (one slot in ShenyuMcpExchangeHolder per session) and the captured response (one lastSentMessage slot per transport) — and this fixes both by keying each to the request instead of the session.
What I verified:
- The fallback is genuinely preserved.
getOriginExchangenow triesmcpExchange.transportContext()first and only thenShenyuMcpExchangeHolder.get(sessionId), so transports that do not populate the context keep working. The two new unit tests pin both directions (context wins, holder still consulted) — good, because that ordering is the whole compatibility story. - Leaving the old single-slot
isResponseReady()in place is correct, not an oversight. I checked both remaining callers::880is the initialize-handshake completion check and:973is the "is this session initialised" heuristic. Both run during session creation, where there is exactly one request in flight, so they do not need per-id correlation. Scoping the newresponsesmap to the concurrent business-request path is the right call. doOnNext→doFinallymatters more than it looks. A cancelled request previously leftlastSentMessagepopulated, and that stale message is precisely what the next request on the session could read. Releasing on cancel and error, not just success, closes it.removeSessionmarkingclosed = trueis safe across threads —closedisvolatile(:1059), andsendMessagechecks it before capturing, so a discarded transport stops accumulating state while another thread is mid-send.
The tests are the right kind: testConcurrentRequestsKeepExchangeAndResponseCorrelation drives eight concurrent requests through one session with a per-request marker and asserts each response carries its own id and marker — that fails on master on both counts (every callback resolves to the last-bound exchange, and the single response slot mismatches ids), so it is a real regression test rather than a restatement of the implementation.
Non-blocking
- One map entry can outlive a cancelled request.
doFinallyremovesresponses.remove(messageId), but if the client cancels before the upstream response arrives, that remove is a no-op and the late response is then inserted and stays untilremoveSessionor a fullresetCapturedMessage(). It is one entry per cancelled in-flight request, bounded by session lifetime — but on a long-lived session with lots of client cancellations (users closing a tab mid-tool-call) it grows. Bounding the map, or recording cancelled ids sosendMessagecan skip them, would close it. - The fallback is now silent. The old
CRITICAL: No exchange found ...ERROR was replaced by a debug line inprocessWithExistingSession. Since falling back to the session holder is exactly the behaviour that is wrong under concurrency, aWARNthere would tell operators whether the context path is actually being taken in their deployment. Mono.defer(() -> session.handle(message))beforecontextWriteis worth a comment: it guarantees the handler observes this request's context rather than anything captured at assembly time, and it is the sort of thing a later cleanup would remove.
…olation-independent # Conflicts: # shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/transport/ShenyuStreamableHttpServerTransportProvider.java
Aias00
left a comment
There was a problem hiding this comment.
Re-reviewed: isolating legacy MCP session requests and reworking the streamable HTTP transport provider is a focused fix with tests on both sides. CI is green on this head — approving.
What happened
Concurrent requests in the same legacy MCP session could reuse session-level transport state and associate an exchange or response with the wrong JSON-RPC request. This could cause missing results, mismatched IDs, or failures in one request affecting later requests.
Changes
Verification
Master reconciliation
Merged current master without rewriting published history. Keep request-scoped transport context, per-ID business-response lookup and doFinally cleanup on completion/error/cancellation; preserve master's compatibility lookup and initialization-handshake slot. Reconcile the two competing response maps into one Object-ID map so compatibility lookup cannot retain a duplicate response after cleanup. Add a regression for cleanup through the compatibility getter and null-ID reset.
Local Maven wrapper: 50 targeted MCP transport/callback/plugin/handler tests passed, zero failures/errors/skips; Checkstyle and MCP Apache RAT passed. Expected negative-path SDK/reflection exception logs did not fail the tests. New head 9f7f043 is pushed; GitHub CI is running and not yet verified. No other open PR branch was merged.