Skip to content

Commit 756fc4f

Browse files
committed
Fix quadratic time reading large stdio responses
stdout_reader rebuilt the whole buffer on every chunk, so one big JSON line was copied once per read. Keep unfinished pieces in a list and join them once a newline arrives. Co-authored-by: maxiaochaos <maxiaochaos@users.noreply.github.com>
1 parent 754254c commit 756fc4f

2 files changed

Lines changed: 38 additions & 3 deletions

File tree

‎src/mcp/client/stdio.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,16 @@ async def stdout_reader() -> None:
147147
async with read_stream_writer:
148148
try:
149149
# One line at a time; no read-ahead while a delivery is blocked.
150-
buffer = ""
150+
# An unfinished line is kept as pieces and joined once a newline
151+
# arrives. Rebuilding the whole buffer on every chunk is quadratic
152+
# in the size of one large JSON line.
153+
parts: list[str] = []
151154
async for chunk in stdout:
152-
lines = (buffer + chunk).split("\n")
153-
buffer = lines.pop()
155+
if "\n" not in chunk:
156+
parts.append(chunk)
157+
continue
158+
lines = ("".join(parts) + chunk).split("\n")
159+
parts = [lines.pop()]
154160
for line in lines:
155161
try:
156162
await read_stream_writer.send(_parse_line(line))

‎tests/client/test_stdio.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,35 @@ async def test_messages_split_and_packed_across_chunks_are_reframed(monkeypatch:
277277
await read_stream.receive()
278278

279279

280+
@pytest.mark.anyio
281+
async def test_a_large_line_split_across_many_chunks_is_delivered_intact(
282+
monkeypatch: pytest.MonkeyPatch,
283+
) -> None:
284+
"""A single JSON-RPC line split into many chunks is delivered as that one message.
285+
286+
Pins SDK stdout framing for a multi-megabyte line (issue #3699): the pieces are
287+
joined when the newline arrives, and the following line still frames normally.
288+
"""
289+
text = "x" * (8 * 1024 * 1024)
290+
response = JSONRPCResponse(jsonrpc="2.0", id=1, result={"text": text})
291+
following = JSONRPCRequest(jsonrpc="2.0", id=2, method="ping")
292+
wire = response.model_dump_json(by_alias=True, exclude_unset=True).encode()
293+
process = FakeProcess(on_stdin_close=lambda: process.exit(0))
294+
295+
install_fake_process(monkeypatch, process)
296+
297+
# 8 MiB in 1 KiB pieces: recopying the buffer on every chunk cannot finish
298+
# inside the fail_after bound, so a quadratic reader fails this test.
299+
with anyio.fail_after(5):
300+
async with stdio_client(FAKE_PARAMS) as (read_stream, _):
301+
for start in range(0, len(wire), 1024):
302+
await process.feed(wire[start : start + 1024])
303+
await process.feed(b"\n" + _line(following))
304+
305+
assert await _next_message(read_stream) == response
306+
assert await _next_message(read_stream) == following
307+
308+
280309
@pytest.mark.anyio
281310
async def test_each_outgoing_message_is_written_as_exactly_one_line(monkeypatch: pytest.MonkeyPatch) -> None:
282311
"""Client -> server framing writes one line per message.

0 commit comments

Comments
 (0)