Skip to content

Commit 9b171b7

Browse files
committed
Fix quadratic time reading large stdio responses
stdout_reader rebuilt "buffer + chunk" for every chunk, so a single large JSON line was re-copied once per chunk read. Receiving N bytes cost O(N^2). Keep the pieces of the unfinished line in a list and join them once when a chunk contains a newline.
1 parent 754254c commit 9b171b7

2 files changed

Lines changed: 26 additions & 3 deletions

File tree

‎src/mcp/client/stdio.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,13 @@ 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+
parts: list[str] = []
151151
async for chunk in stdout:
152-
lines = (buffer + chunk).split("\n")
153-
buffer = lines.pop()
152+
if "\n" not in chunk:
153+
parts.append(chunk) # joined once the line ends, not re-copied per chunk
154+
continue
155+
lines = ("".join(parts) + chunk).split("\n")
156+
parts = [lines.pop()]
154157
for line in lines:
155158
try:
156159
await read_stream_writer.send(_parse_line(line))

‎tests/client/test_stdio.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,26 @@ 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_message_in_many_small_chunks_is_reframed_in_linear_time(
282+
monkeypatch: pytest.MonkeyPatch,
283+
) -> None:
284+
"""Receiving one huge line must not re-copy everything received so far for every chunk."""
285+
big = JSONRPCResponse(jsonrpc="2.0", id=1, result={"text": "x" * (8 * 1024 * 1024)})
286+
wire = _line(big)
287+
process = FakeProcess(on_stdin_close=lambda: process.exit(0))
288+
289+
install_fake_process(monkeypatch, process)
290+
291+
# Quadratic reframing of this takes tens of seconds; linear takes well under one.
292+
with anyio.fail_after(5):
293+
async with stdio_client(FAKE_PARAMS) as (read_stream, _):
294+
for start in range(0, len(wire), 1024):
295+
await process.feed(wire[start : start + 1024])
296+
297+
assert await _next_message(read_stream) == big
298+
299+
280300
@pytest.mark.anyio
281301
async def test_each_outgoing_message_is_written_as_exactly_one_line(monkeypatch: pytest.MonkeyPatch) -> None:
282302
"""Client -> server framing writes one line per message.

0 commit comments

Comments
 (0)