Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/readbuffer-release-consumed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

`ReadBuffer` now releases its backing allocation once the last buffered message is consumed, instead of retaining it through an empty `Buffer` view. Long-lived stdio transports no longer pin consumed chunks (pooled or multi-message buffers) until the next append, and the append after a full drain assigns the incoming chunk directly instead of copying through `Buffer.concat`. Fixes #2536
8 changes: 7 additions & 1 deletion packages/core-internal/src/shared/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ export class ReadBuffer {
}

const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, '');
this._buffer = this._buffer.subarray(index + 1);
// Drop the buffer entirely once the last message is consumed: an
// empty subarray view is truthy and keeps the backing allocation
// (which may hold far more than this message, e.g. pooled or
// multi-message chunks) alive, and forces the next append through
// Buffer.concat.
const remainder = this._buffer.subarray(index + 1);
this._buffer = remainder.length === 0 ? undefined : remainder;

try {
return deserializeMessage(line);
Expand Down
31 changes: 31 additions & 0 deletions packages/core-internal/test/shared/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,37 @@ test('should be reusable after clearing', () => {
expect(readBuffer.readMessage()).toEqual(testMessage);
});

test('releases the backing allocation once the last message is consumed', () => {
const readBuffer = new ReadBuffer();
readBuffer.append(Buffer.from(JSON.stringify(testMessage) + '\n'));

expect(readBuffer.readMessage()).toEqual(testMessage);
// An empty subarray view is truthy and keeps the backing allocation alive
// (one chunk can hold several messages, or be a pooled buffer); the
// buffer must be dropped entirely instead.
expect((readBuffer as unknown as { _buffer?: Buffer })._buffer).toBeUndefined();
});

test('appends directly after a full drain', () => {
const readBuffer = new ReadBuffer();
readBuffer.append(Buffer.from(JSON.stringify(testMessage) + '\n'));
readBuffer.readMessage();

readBuffer.append(Buffer.from(JSON.stringify(testMessage) + '\n'));
expect(readBuffer.readMessage()).toEqual(testMessage);
expect(readBuffer.readMessage()).toBeNull();
});

test('keeps a partial next message when bytes follow the newline', () => {
const readBuffer = new ReadBuffer();
readBuffer.append(Buffer.from(JSON.stringify(testMessage) + '\n' + JSON.stringify(testMessage)));
expect(readBuffer.readMessage()).toEqual(testMessage);

readBuffer.append(Buffer.from('\n'));
expect(readBuffer.readMessage()).toEqual(testMessage);
expect(readBuffer.readMessage()).toBeNull();
});

describe('non-JSON line filtering', () => {
test('should skip empty lines', () => {
const readBuffer = new ReadBuffer();
Expand Down
Loading