diff --git a/.changeset/readbuffer-release-consumed.md b/.changeset/readbuffer-release-consumed.md new file mode 100644 index 0000000000..667fdee37c --- /dev/null +++ b/.changeset/readbuffer-release-consumed.md @@ -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 diff --git a/packages/core-internal/src/shared/stdio.ts b/packages/core-internal/src/shared/stdio.ts index 8bd794b87b..dc0f2da0de 100644 --- a/packages/core-internal/src/shared/stdio.ts +++ b/packages/core-internal/src/shared/stdio.ts @@ -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); diff --git a/packages/core-internal/test/shared/stdio.test.ts b/packages/core-internal/test/shared/stdio.test.ts index f8d27a4c1f..d28a89bb74 100644 --- a/packages/core-internal/test/shared/stdio.test.ts +++ b/packages/core-internal/test/shared/stdio.test.ts @@ -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();