pi-mono/packages/mcp/test/lines.test.ts
robopipi[bot] f6278a472e
fix(mcp): read large stdio messages in linear time (#11791)
`JsonRpcLineReader.readStdout` (used by the stdio transport and by
`PipeTransport`) appended every stdout chunk to the buffered bytes with
`Buffer.concat`, then searched the whole buffer from the start for a
newline. A large message arriving in many chunks was therefore copied
and scanned again on every chunk. A 12 MiB tool result in 64 KiB chunks
took about 350 ms just to frame.

Now the reader keeps the bytes of an incomplete message as a list of
chunks, with a running byte count, and searches only the new chunk for a
newline. When the newline arrives, the chunks are joined once. A line
that fits inside a single chunk is sliced directly. The rest is
unchanged: the `maxMessageBytes` limit (now checked against the running
count), CRLF and blank-line handling, errors for invalid JSON and
oversized messages, and the incomplete-message error in `end()`.

Testing:
- New `packages/mcp/test/lines.test.ts`:
- a 1 MiB message fed in 4 KiB chunks calls `Buffer.concat` once (fails
on the old code);
- a mixed input of multi-byte UTF-8, CRLF, blank lines, an invalid line
and several messages gives the same results at every chunk size from 1
byte to the whole input;
  - reading recovers after an oversized message;
  - a message without its newline is reported at the end.
- `test/lines.test.ts`, `test/stdio.test.ts` and `test/pipe.test.ts`
pass. `npm run check` passes.
- Micro-benchmark, one message fed in 64 KiB chunks:
  - before: 1 MiB 5.4 ms, 4 MiB 49.9 ms, 12 MiB 348 ms;
  - after: 2.2 ms, 7.9 ms, 21.6 ms.

Fixes #11761

Requested by @badlogic

Co-authored-by: triagent <triagent@localhost>
2026-11-04 10:09:23 +01:00

75 lines
2.8 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import type { JsonRpcMessage } from "../src/index.ts";
import { JsonRpcLineReader } from "../src/transports/lines.ts";
function startReader(maxMessageBytes?: number) {
const messages: JsonRpcMessage[] = [];
const errors: string[] = [];
const reader = new JsonRpcLineReader({
maxMessageBytes,
onMessage: (message) => messages.push(message),
onError: (error) => errors.push(error instanceof Error ? error.message : String(error)),
});
return { reader, messages, errors };
}
function feed(reader: JsonRpcLineReader, bytes: Buffer, chunkSize: number): void {
for (let i = 0; i < bytes.length; i += chunkSize) reader.readStdout(bytes.subarray(i, i + chunkSize));
}
describe("JsonRpcLineReader", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("joins a message split across many chunks once, when its newline arrives", () => {
const { reader, messages, errors } = startReader();
const text = "x".repeat(1024 * 1024);
const bytes = Buffer.from(`${JSON.stringify({ jsonrpc: "2.0", id: 1, result: { text } })}\n`);
const concat = vi.spyOn(Buffer, "concat");
feed(reader, bytes, 4096);
expect(concat).toHaveBeenCalledTimes(1);
expect(errors).toEqual([]);
expect(messages).toEqual([{ jsonrpc: "2.0", id: 1, result: { text } }]);
});
it("handles multi-byte characters, CRLF, blank lines, and several messages per chunk at any split", () => {
const input = Buffer.from(
[
JSON.stringify({ jsonrpc: "2.0", id: 1, result: { text: "héllo 🌍" } }),
"",
`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/x" })}\r`,
"not json",
JSON.stringify({ jsonrpc: "2.0", id: 2, result: {} }),
"",
].join("\n"),
);
for (let chunkSize = 1; chunkSize <= input.length; chunkSize++) {
const { reader, messages, errors } = startReader();
feed(reader, input, chunkSize);
reader.end();
expect(messages).toEqual([
{ jsonrpc: "2.0", id: 1, result: { text: "héllo 🌍" } },
{ jsonrpc: "2.0", method: "notifications/x" },
{ jsonrpc: "2.0", id: 2, result: {} },
]);
expect(errors).toHaveLength(1);
}
});
it("drops an oversized message and keeps reading the next one", () => {
const { reader, messages, errors } = startReader(64);
const next = JSON.stringify({ jsonrpc: "2.0", id: 2, result: {} });
feed(reader, Buffer.from(`${"a".repeat(50)}`), 10);
feed(reader, Buffer.from(`${"a".repeat(50)}\n${next}\n`), 10);
expect(errors[0]).toBe("MCP stdio message exceeds 64 bytes");
expect(messages).toEqual([{ jsonrpc: "2.0", id: 2, result: {} }]);
});
it("reports a message without its newline at the end", () => {
const { reader, errors } = startReader();
feed(reader, Buffer.from('{"jsonrpc":"2.0",'), 4);
reader.end();
expect(errors).toEqual(["MCP stdio server closed with an incomplete JSON-RPC message"]);
});
});