Repository navigation
Conversation
…umer session policy (apache#8195)
Aias00
left a comment
There was a problem hiding this comment.
Stream Agent analysis
PR #8350 (head f336195) addresses issue #8195 by bounding server-produced SSE queues and adding slow-consumer session lifetime handling in the MCP server plugin. Reference metadata lists 7 changed files (ShenyuMcpServerManager.java +19; ShenyuSseServerTransportProvider.java +255/-77; new SseSessionPolicy.java +188; SseSessionRejectException.java +57; SseSessionState.java +264; tests ShenyuSseServerTransportProviderTest.java +535 and SseSessionPolicyTest.java +73). CI is reported complete with passed=true and zero failed checks. However, in this review session the VERSIONED_DIFF index (empty path, base/head resource IDs, following next_offset) was never retrieved, and only partial head-version source of ShenyuSseServerTransportProvider.java was read via resource_read; no LEFT/RIGHT line-level diff content for any of the 7 files was verified.
Evidence
- Reference material: head_sha f336195, base_sha 548bec1, 7 files changed with stated addition/deletion counts; CI complete=true, passed=true, failed_checks=[].
- Partial head content of ShenyuSseServerTransportProvider.java shows newSessionStream using Flux.defer/Flux.create with SseSessionState, STALL_SCHEDULER and terminateSession, plus bounded counters getAcceptedSendCount/getRejectedSendCount/getTotalPendingEvents/getTotalPendingBytes.
- diff_read in reference requires all_pages_required=true via resource_diff empty path; this index page was not read in the session, so full diff coverage is unverified.
Recommended next steps
- Read VERSIONED_DIFF index with empty path and the pinned base/head resource IDs, following next_offset until has_more=false, to capture all 7 file diffs.
- Verify line-level LEFT/RIGHT changes for ShenyuMcpServerManager.java, SseSessionPolicy.java, SseSessionState.java, SseSessionRejectException.java and both test files before any PASS conclusion.
Scope: captured diff and GitHub check metadata only; no local tests were executed.
Task: 099719de-c5c9-43fc-b9c0-bd233229598e · Commit: f33619535fd10f474a8594a9f7f4984166d2bf09
Review verdict: INCONCLUSIVE
Full diff coverage is incomplete. The VERSIONED_DIFF index (empty path, base/head resource IDs) was not read following next_offset, and only one file's head-version content was partially retrieved; line-level LEFT/RIGHT diff evidence for all seven changed files was not verified. Per the review contract, PASS cannot be asserted without full diff coverage, and no concrete defect could be confirmed from the limited evidence available.
Publication: COMMENT (REVIEW_EVIDENCE_MISSING)
On the INCONCLUSIVE verdictThanks @Aias00. The verdict describes a gap in the review run's own evidence gathering: the Where the change lives (head
|
Fixes #8195.
Problem
The SSE transport of the MCP server plugin produces server-initiated JSON-RPC responses and notifications through
Flux.create(...)with Reactor's default buffering strategy. When the SSE client stops reading, Reactor Netty stops requesting downstream, and everysink.next(...)call keeps buffering events in an unbounded internal queue: a single slow or dead consumer can grow the gateway heap without limit. The completion ofsendMessageonly signals submission into that buffer, so a tool response is indistinguishable from one that was actually written, and nothing ever reclaims a session whose consumer stopped reading — the session, its budget-free buffer and its exchange-holder entry live until the client disconnects.Both public SSE entry points (
handleSseConnectionandcreateSseFlux) carried a copy of the same unbounded stream setup, so any fix had to land twice or the gap would remain in one of them.Approach
Both entry points now share one
newSessionStream()implementation: each subscription owns exactly one MCP session, one admission state and one stream sink, wrapped inFlux.deferso per-subscription state can never leak across connections.Admission is bounded per session by a
SseSessionPolicy(defaults: 256 pending events, 1 MiB pending serialized UTF-8 bytes, 30s output-stall timeout).sendMessageserializes the payload, reserves one event slot plus its exact UTF-8 byte length, and only then hands the event to the stream. The reservation is released by a release hook when the consumer pulls the event out of the transport buffer — that buffer is the only unbounded segment of the pipeline, because downstream writes are already bounded by Reactor Netty's socket-writability water marks. Reservations and releases stay FIFO with the event order, so byte accounting is exact without tagging individual events.Rejections are explicit, never silent drops: a message larger than the whole byte budget is rejected (
OVERSIZED) while the session stays usable for others; a message that does not fit the remaining budget (OVERFLOW) means the consumer stopped making progress, so the session is terminated and the failing send returns an error to the MCP SDK instead of pretending success.A stall detector runs only while output is pending: the first reserved event schedules a check, every consumed event refreshes the progress timestamp, and a session whose pending output makes no progress within the configured bound is terminated. A healthy idle session without pending output is never a candidate.
Termination is idempotent and exactly once: an atomic transition on the session state removes the session and its exchange-holder entry, records a bounded terminal reason (client disconnected, output stalled, output overflow, provider closed) and completes the stream. Completing the stream closes this transport, which is the SDK's own definition of closing the session —
McpServerSession.closeGracefully()delegates to the transport — so outstanding operations of the session finish through their own request timeouts and are never replayed. The dispose path, the stall path, overflow rejection and provider shutdown all funnel into the same transition, so concurrent close attempts cannot clean up twice. Completion of the stream respects normal backpressure: buffered events drain in order for a consumer that resumes reading, and the SSE framing, event names and initial endpoint event are unchanged.Low-cardinality counters (accepted, rejected, and per-reason terminals) are exposed on the provider for diagnostics and tests; neither counters nor log lines carry session identifiers or message content.
The manager accepts an optional policy and passes it to every SSE transport it creates; deployments that do not configure anything get the documented defaults. Configuration exposure through
ShenyuConfig/yaml is deliberately out of scope for this change — the policy object is the extension point.Testing
New
ShenyuSseServerTransportProviderTest(11 cases) drives the real transport with captured SDK sessions: a controlled zero-demand subscription proves the finite overflow outcome (bounded acceptance, deterministic rejection, session cleanup, ordered drain and completion once the consumer resumes); byte-budget cases prove serialized UTF-8 accounting on exact boundaries including a multi-byte payload whose character count matches but whose byte count does not; an oversized single message is rejected without terminating the session; the endpoint event is delivered first with order preserved; an idle session survives the stall window while pending output without progress terminates within the bound; a stalled session does not affect a concurrent healthy one; client disconnect releases all state exactly once. Loopback tests over Reactor Netty cover thehandleSseConnectionentry point end to end: a fast reader receives the endpoint event plus ordered messages, a throttled reader recovers under temporary pressure without loss, and an abrupt disconnect removes the server session.SseSessionPolicyTestlocks the documented defaults and validation.mvn -pl shenyu-plugin/shenyu-plugin-mcp-server testpasses with 240 tests (224 pre-existing + 16 new including the policy tests, timing-sensitive cases re-run three times for stability) and Checkstyle is clean.