Skip to content

[ISSUE #8195] Bound server-produced SSE queues and slow-consumer session lifetime - #8350

Open
konlue wants to merge 1 commit into
apache:masterfrom
konlue:feat/8195-sse-backpressure
Open

konlue wants to merge 1 commit into
apache:masterfrom
konlue:feat/8195-sse-backpressure

Conversation

@konlue

@konlue konlue commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

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 every sink.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 of sendMessage only 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 (handleSseConnection and createSseFlux) 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 in Flux.defer so 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). sendMessage serializes 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 the handleSseConnection entry 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. SseSessionPolicyTest locks the documented defaults and validation.

mvn -pl shenyu-plugin/shenyu-plugin-mcp-server test passes 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.

@Aias00 Aias00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)

@konlue

konlue commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

On the INCONCLUSIVE verdict

Thanks @Aias00. The verdict describes a gap in the review run's own evidence gathering: the VERSIONED_DIFF index was never read, so no line-level diff was captured, and the run states explicitly that no defect could be confirmed from the material it did retrieve. There is nothing blocking in it. For the record, the reviewed head f33619535 has 19 successful check runs, 10 skipped and 0 failures on GitHub, and the local module run is 240 tests with 0 failures and 0 errors. To make the next pass cheaper, here is the exact map of the seven files with anchors in that head.

Where the change lives (head f33619535, 7 files, +1391/-77)

ShenyuMcpServerManager.java (+19): a sseSessionPolicy field (L88) defaults to SseSessionPolicy.defaults() in the existing constructor (L119); the new overload treats null as defaults (L129-131) and forwards the policy to every SSE transport it builds (L271).

ShenyuSseServerTransportProvider.java (+255/-77): this is where the duplicated stream setup disappeared. Both public entry points — handleSseConnection and createSseFlux — now return the single newSessionStream() (L352), wrapped in Flux.defer so each subscription owns exactly one session, one admission state and one sink (L355-390). terminateSession(session, sink, reason) (L408) is the single idempotent cleanup shared by the dispose path, the stall path, overflow rejection and provider shutdown. sendMessage (L611) serializes the payload and calls tryReserve(byteLength) (L637), releasing the reservation on every path that does not emit the event (L648, L656). Builder.policy(...) is at L757.

SseSessionPolicy.java (+188): immutable builder with the documented defaults DEFAULT_MAX_PENDING_EVENTS = 256 (L47), DEFAULT_MAX_PENDING_BYTES = 1 MiB (L53) and DEFAULT_STALL_TIMEOUT = 30s (L58), validated in build() (L180).

SseSessionState.java (+264): package-private per-session state. Reservation (L54) and TerminalReason (L83) enums, tryReserve (L139) returns ACCEPTED, OVERSIZED (single payload larger than the whole byte budget, session stays usable) or OVERFLOW (would exceed either budget) under the state monitor and schedules the first stall check on the 0→1 transition, release() (L169) refunds the next FIFO reservation and refreshes the progress timestamp, terminate() (L199) performs the transition exactly once through compareAndSet inside the same monitor and resets the counters.

SseSessionRejectException.java (+57): carries OVERSIZED / OVERFLOW to the MCP SDK instead of pretending the send succeeded.

Tests: ShenyuSseServerTransportProviderTest (+535, 11 cases) and SseSessionPolicyTest (+73, 5 cases), which is the 16 new tests on top of the 224 pre-existing ones.

Reproducing the verification

The full patch is fetchable without API access at https://patch-diff.githubusercontent.com/raw/apache/shenyu/pull/8350.patch. Locally on this head, mvn -pl shenyu-plugin/shenyu-plugin-mcp-server test reports 240 tests with 0 failures and 0 errors (timing-sensitive cases repeated for stability), and checkstyle:check is clean. If a later pass still cannot read the diff index, say which partition is missing and I will paste that file's hunks inline here.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Task][MCP Server] Bound server-produced SSE queues and slow-consumer session lifetime

2 participants