Skip to content

stdio.ts/ReadBuffer retains consumed input and copies the next stdio chunk #2536

Description

@sebthom

ReadBuffer.readMessage() currently advances the buffer with:

this._buffer = this._buffer.subarray(index + 1);

When the newline is the final byte, subarray() returns an empty Buffer view. The view still references the backing allocation of the accumulated input buffer.

That allocation can contain more than the final message, for example when one input chunk contains multiple messages or uses a pooled buffer. It stays referenced until another chunk is appended, clear() is called, or the transport closes.

There is also a cost on the next append. An empty Buffer is truthy, so this code still takes the concat path:

this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk;

The next append therefore copies the incoming chunk even though no buffered bytes remain. During that copy, the retained backing allocation, the incoming chunk, and the new concat destination are all live.

This is not an unbounded leak, but it unnecessarily increases idle external memory, peak memory, and copying for long-lived stdio processes. The extra peak memory matters when large messages are allowed or the process runs under a memory limit.

I confirmed this behavior in:

  • the currently published @modelcontextprotocol/sdk v1.29.0
  • the current v1.x branch:
    readMessage(): JSONRPCMessage | null {
    if (!this._buffer) {
    return null;
    }
    const index = this._buffer.indexOf('\n');
    if (index === -1) {
    return null;
    }
    const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, '');
    this._buffer = this._buffer.subarray(index + 1);
    return deserializeMessage(line);
    }
  • the current main branch used by the v2 packages:
    readMessage(): JSONRPCMessage | null {
    while (this._buffer) {
    const index = this._buffer.indexOf('\n');
    if (index === -1) {
    return null;
    }
    const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, '');
    this._buffer = this._buffer.subarray(index + 1);
    try {
    return deserializeMessage(line);
    } catch (error) {
    // Skip non-JSON lines (e.g., debug output from hot-reload tools like
    // tsx or nodemon that write to stdout). Schema validation errors still
    // throw so malformed-but-valid-JSON messages surface via onerror.
    if (error instanceof SyntaxError) {
    continue;
    }
    throw error;
    }
    }
    return null;
    }

A small fix would clear the buffer when no bytes remain:

const remainder = this._buffer.subarray(index + 1);
this._buffer = remainder.length === 0 ? undefined : remainder;

This does not copy non-empty remainders or change the public API. It releases the consumed backing allocation and lets the next append assign its chunk directly instead of using Buffer.concat().

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions