-
Notifications
You must be signed in to change notification settings - Fork 4k
Buffer replay before network delivery and preserve the live handoff #3541
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: preserve-request-stream-ownership
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| responses, with streaming support for long-running operations. | ||
| """ | ||
|
|
||
| import json | ||
| import logging | ||
| import math | ||
| import re | ||
|
|
@@ -210,11 +211,13 @@ def __init__( | |
| self.mcp_session_id = mcp_session_id | ||
| self.is_json_response_enabled = is_json_response_enabled | ||
| self._event_store = event_store | ||
| self._event_store_lock = anyio.Lock() | ||
| self._security = TransportSecurityMiddleware(security_settings) | ||
| self._retry_interval = retry_interval | ||
| self._request_streams: dict[RequestId, _RequestStreams] = {} | ||
| self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[SSEEvent]] = {} | ||
| self._terminated = False | ||
| self._connected = False | ||
| self._idle_timeout = idle_timeout | ||
| self._requests_in_flight = 0 | ||
| self.idle_scope: anyio.CancelScope | None = None | ||
|
|
@@ -915,25 +918,43 @@ async def _replay_events(self, last_event_id: str, request: Request, send: Send) | |
| async def replay_sender(): | ||
| stream_id: StreamId | None = None | ||
| request_streams: _RequestStreams | None = None | ||
| priming_event: SSEEvent | None = None | ||
| replay_buffer = anyio.SpooledTemporaryFile(max_size=1024 * 1024) | ||
| try: | ||
| async with sse_stream_writer: | ||
|
|
||
| async def send_event(event_message: EventMessage) -> None: | ||
| await sse_stream_writer.send(self._create_event_data(event_message)) | ||
|
|
||
| stream_id = await event_store.replay_events_after(last_event_id, send_event) | ||
| if stream_id and stream_id not in self._request_streams: # pragma: no branch | ||
| request_streams = anyio.create_memory_object_stream[EventMessage]( | ||
| REQUEST_STREAM_BUFFER_SIZE | ||
| event_data = self._create_event_data(event_message) | ||
| # Whole records prevent ping frames from splitting a replayed SSE event. | ||
| await replay_buffer.write( | ||
| (json.dumps(event_data, ensure_ascii=False) + "\n").encode("utf-8") | ||
| ) | ||
|
Comment on lines
+929
to
931
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Operators whose server has no writable temp directory lose Last-Event-ID resumption for any stream holding over 1 MiB of history; on the base branch that history streamed fine. The buffer write at src/mcp/server/streamable_http.py:929 goes into anyio.SpooledTemporaryFile, whose spill opens tempfile.TemporaryFile in gettempdir() from a worker thread. The OSError escapes send_event and aborts replay_events_after, so the client gets an empty SSE stream on every retry. Fix: keep replay deliverable without a writable temp dir, e.g. catch OSError from the spill and keep buffering in memory (or make the spill dir/threshold configurable). Extended reasoning...Condition: the process runs with no writable temp location (Docker --read-only without --tmpfs /tmp, Kubernetes readOnlyRootFilesystem with no emptyDir at /tmp, TMPDIR set to a missing path, or a full disk) and a resumed stream has more than 1 MiB of un-acknowledged history, e.g. a long tool call emitting many progress or log notifications while the client was disconnected. On the base branch replay_sender sent each replayed event straight to sse_stream_writer with no filesystem involvement. After the change, send_event at streamable_http.py:926-931 writes every record into replay_buffer created at line 922 with max_size=1024*1024. anyio's SpooledTemporaryFile checks tell() against max_size on write and calls rollover(), which runs tempfile.TemporaryFile(mode='w+b', dir=None, ...) via to_thread.run_sync; tempfile.gettempdir() raises FileNotFoundError('No usable temporary directory found') when TMPDIR, /tmp, /var/tmp, /usr/tmp and cwd are all unwritable, and a full disk raises OSError(ENOSPC) on the copy. That exception propagates out of send_event, through the store's… Verification: normal — triggered when the server process has no writable temp location (read-only root filesystem without a /tmp tmpfs/emptyDir, TMPDIR pointing at a missing path, or ENOSPC) and a resumed stream carries more than 1 MiB of replayable history. Mechanism verified in /home/claude/python-sdk/src/mcp/server/streamable_http.py: line 922 creates `replay_buffer =… | normal — triggers when the process… |
||
| self._sse_stream_writers[stream_id] = sse_stream_writer | ||
| self._request_streams[stream_id] = request_streams | ||
| priming_event = await self._mint_priming_event(stream_id, replay_protocol_version) | ||
| if priming_event is not None: | ||
| await sse_stream_writer.send(priming_event) | ||
| async with request_streams[1] as msg_reader: | ||
| async for event_message in msg_reader: | ||
| await sse_stream_writer.send(self._create_event_data(event_message)) | ||
|
|
||
| async with self._event_store_lock: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a POST mints its priming cursor during a replay, Prompt for AI agents |
||
| if self._terminated or not self._connected: | ||
| return | ||
| stream_id = await event_store.replay_events_after(last_event_id, send_event) | ||
|
Comment on lines
+933
to
+936
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) Every message in a session now waits while one client's replay reads the event store, which the base branch never did. streamable_http.py:936 calls Extended reasoning...The PR text says the lock lets a slow replay reader not block sibling responses; that covers the network reader, not the store read itself, which now runs entirely under the lock. On the base the replay called replay_events_after with no lock and the router only serialized on its own store_event call, so a long replay never delayed other messages. After the merge: a client reconnects with Last-Event-ID to a session backed by a database or remote store holding a long history. replay_sender enters Verification: nit; conflicts with stated purpose: the PR description says the lock is held so that "a slow reader cannot block sibling responses", but that only covers network delivery — the event-store read itself now runs under the lock and stalls every outbound message in the session for its full duration. Trigger: a client reconnects with Last-Event-ID to a session whose EventStore is slow… | nit —… |
||
| if self._terminated or not self._connected: | ||
| return | ||
| if stream_id and stream_id not in self._request_streams: | ||
| request_streams = anyio.create_memory_object_stream[EventMessage]( | ||
| REQUEST_STREAM_BUFFER_SIZE | ||
| ) | ||
| self._sse_stream_writers[stream_id] = sse_stream_writer | ||
| self._request_streams[stream_id] = request_streams | ||
| priming_event = await self._mint_priming_event(stream_id, replay_protocol_version) | ||
|
|
||
| await replay_buffer.seek(0) | ||
| while event_data := await replay_buffer.readline(): | ||
| await sse_stream_writer.send(json.loads(event_data)) | ||
|
Comment on lines
+943
to
+949
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) Servers using the polling pattern ( Extended reasoning...Tools that implement polling call ctx.close_sse_stream() periodically. A client reconnects with Last-Event-ID and has a large history (the spilled case is >1 MiB). Under the lock at line 943 the new code stores the writer in _sse_stream_writers, then releases the lock and sends the history record by record at line 949. The tool's next close_sse_stream(request_id) at line 273 pops and closes that writer and pops the request streams at line 279. The next send at line 949 raises ClosedResourceError, caught at line 958; the stream ends after a partial history with no priming event. The client waits the retry interval, reconnects, and the remaining history is scanned again under _event_store_lock, stalling the session router again; with a close interval shorter than the drain time this repeats for every chunk. On the base the writer was registered only after replay_events_after finished (old line 928), so close during replay was a no-op and the history arrived in one pass. No event is lost, but each resume becomes many round trips and many locked store scans. Remedy: defer the close… Verification: nit. Trigger: a server using the polling pattern (a tool calling |
||
| await replay_buffer.aclose() | ||
| if request_streams is None or self._terminated or not self._connected: | ||
| return | ||
| if priming_event is not None: | ||
| await sse_stream_writer.send(priming_event) | ||
| async with request_streams[1] as msg_reader: | ||
| async for event_message in msg_reader: | ||
| await sse_stream_writer.send(self._create_event_data(event_message)) | ||
| except anyio.ClosedResourceError: # pragma: lax no cover | ||
| # Expected when close_sse_stream() is called | ||
| logger.debug("Replay SSE stream closed by close_sse_stream()") | ||
|
Comment on lines
+947
to
960
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) Clients resuming against an event store that fails partway through a long scan now receive nothing and can never advance, where the base delivered everything up to the failure. streamable_http.py:936 buffers all events under the lock and only sends at line 947 after Extended reasoning...A store backed by a database or Redis scan with a query timeout trips only on long scans. On the base, send_event wrote each event straight to the SSE writer, so a timeout after N events still delivered N events; the client reconnected with the Nth id and the next scan was shorter and succeeded. Now send_event at lines 926-931 only writes to replay_buffer, and the buffer is read back at lines 947-949 only after replay_events_after returns normally. If the store raises, control jumps to line 961, logs, and the finally closes the buffer; the SSE response ends with zero events. The client reconnects with the same Last-Event-ID, the same long scan runs again under _event_store_lock (stalling the session's router each time), times out again, and the client never progresses. The dismissing finder read the code but judged only 'partial delivery differs'; it did not follow the retry loop where partial delivery is what made progress possible. Remedy: deliver the already-buffered records before ending the stream on a store error. Verification: nit — triggers when a user-supplied |
||
|
|
@@ -945,6 +966,8 @@ async def send_event(event_message: EventMessage) -> None: | |
| if request_streams is not None: | ||
| assert stream_id is not None | ||
| self._clean_up_memory_streams(stream_id, request_streams) | ||
| with anyio.CancelScope(shield=True): | ||
| await replay_buffer.aclose() | ||
|
|
||
| # Create and start EventSourceResponse | ||
| response = EventSourceResponse( | ||
|
|
@@ -999,10 +1022,12 @@ async def connect( | |
| self._write_stream_reader = write_stream_reader | ||
| self._write_stream = write_stream | ||
|
|
||
| acquire_scope: anyio.CancelScope | None = None | ||
| # Start a task group for message routing | ||
| async with anyio.create_task_group() as tg: | ||
| # Create a message router that distributes messages to request streams | ||
| async def message_router(): | ||
| nonlocal acquire_scope | ||
| try: | ||
| async for session_message in write_stream_reader: # pragma: no branch | ||
| # Determine which request stream(s) should receive this message | ||
|
|
@@ -1038,10 +1063,28 @@ async def message_router(): | |
| # messages will be replayed on the re-connect | ||
| event_id = None | ||
| if self._event_store: | ||
| event_id = await self._event_store.store_event(request_stream_id, message) | ||
| logger.debug(f"Stored {event_id} from {request_stream_id}") | ||
| with anyio.CancelScope() as lock_scope: | ||
| acquire_scope = lock_scope | ||
| try: | ||
| try: | ||
| self._event_store_lock.acquire_nowait() | ||
| except anyio.WouldBlock: | ||
| if not self._connected: | ||
| return | ||
| await self._event_store_lock.acquire() | ||
| finally: | ||
| acquire_scope = None | ||
| if lock_scope.cancelled_caught: | ||
| return | ||
| try: | ||
| event_id = await self._event_store.store_event(request_stream_id, message) | ||
| logger.debug(f"Stored {event_id} from {request_stream_id}") | ||
| target = self._request_streams.get(request_stream_id) | ||
| finally: | ||
| self._event_store_lock.release() | ||
| else: | ||
| target = self._request_streams.get(request_stream_id) | ||
|
|
||
| target = self._request_streams.get(request_stream_id) | ||
| if target is not None: | ||
| try: | ||
| # Send both the message and the event ID | ||
|
Comment on lines
1063
to
1090
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) Every other request in a session stalls while one reconnecting client drains a large replay, which the base branch never did during history delivery. streamable_http.py:944 registers the resumed stream under the lock before the buffered history is sent at :947-:949; live events for that stream now queue into the 16-slot buffer during the drain, and on the 17th the router blocks at :1091, so no response or notification in the session moves until the slow client finishes reading history. Fix: keep the live stream unregistered (or drain it) until history delivery completes, so the router only blocks on a stream that is actually being consumed; the base registered at the same point but only after the history was already on the wire. [also at: src/mcp/server/streamable_http.py:944 - Every other request in a session can stall for as long as one resuming client takes to download its history, which the base branch never did during replay. streamable_http.py:944 registers the resumed stream in Extended reasoning...On the base, replay_events_after streamed history straight to the network and only afterwards registered request_streams, so live events for that stream during history delivery hit target None at :1094 and were stored without queuing. The PR moves registration to :944, inside the lock and before the drain loop at :947-:949. A client reconnects with Last-Event-ID to a GET stream (or a long-running request stream) whose history is large, up to and above the 1 MiB spill, over a slow link. During the drain the server keeps emitting notifications for that stream (progress, logging, list_changed). Each goes through the router: store_event, then target[0].send at :1091 into the 16-slot buffer created at :940. The 17th send blocks the router. The router is the only consumer of write_stream for the whole session,… Verification: nit — acknowledged in diff: docs/run/legacy-clients.md adds "Live-stream backpressure can still delay other messages in the same session", and that bound is accurate (a delay for the remainder of the drain, not a deadlock); but the PR description's claim that delivering history outside the lock means "a slow reader cannot block sibling responses" is only true for the lock, not for the… |
||
|
|
@@ -1066,8 +1109,10 @@ async def message_router(): | |
| tg.start_soon(message_router) | ||
|
|
||
| try: | ||
| self._connected = True | ||
| yield read_stream, write_stream | ||
| finally: | ||
| self._connected = False | ||
| for stream_id, streams in list(self._request_streams.items()): | ||
| self._clean_up_memory_streams(stream_id, streams) | ||
|
|
||
|
|
@@ -1080,3 +1125,6 @@ async def message_router(): | |
| except Exception as e: # pragma: no cover | ||
| # During cleanup, we catch all exceptions since streams might be in various states | ||
| logger.debug(f"Error closing streams: {e}") | ||
| # Cancel only lock acquisition, not a store write that has already started. | ||
| if acquire_scope is not None: | ||
| acquire_scope.cancel() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 (optional) Servers whose event store builds
EventMessagewith a non-JSON event id (auuid.UUID,Decimal, DB id object) lose replay after this merges; the base delivered those events. streamable_http.py:930 runsjson.dumpson the event dict, whoseidisEventMessage.event_idexactly as the store passed it.json.dumpsraisesTypeErrorfor such objects; the handler at streamable_http.py:961 logs it and the client gets an empty 200 stream and retries the same cursor forever. Fix: coerceevent_idtostrin_create_event_data(streamable_http.py:437) or passdefault=str, so the live and replay paths accept the same ids. Third-party stores cannot be enumerated from this checkout; the SDK's own stores all passstr.Extended reasoning...
EventMessage is a plain dataclass (streamable_http.py:106-111) and its event_id annotation is not enforced; stores construct it themselves inside replay_events_after. On the base, send_event handed the dict straight to sse_starlette, which renders the id with string formatting, so a UUID object, a Decimal or a driver row id worked on the wire; the same objects still work on the live path after the merge because _create_event_data at line 428-439 only copies them. On the replay path after the merge: a store such as a Postgres-backed one whose driver returns uuid.UUID for the id column calls send_callback(EventMessage(message, row.id)). send_event at streamable_http.py:926-931 builds event_data with
idset to that UUID object and callsjson.dumps(event_data, ensure_ascii=False)at line 930, which raises TypeError: Object of type UUID is not JSON serializable. The exception leaves replay_events_after, exits the lock, and is caught byexcept Exceptionat line 961, which logs Error in replay sender; the finally closes the buffer. Theasync with sse_stream_writerblock ends, so the…Verification: nit. Trigger: a third-party EventStore whose replay_events_after builds EventMessage with a non-str event_id (uuid.UUID, Decimal, DB row id), violating the declared but runtime-unenforced type. Mechanism verified: src/mcp/server/streamable_http.py:106-111 declares
event_id: str | Noneon a plain dataclass (no runtime check);_create_event_data(lines 428-439) copiesevent_message.event_id…