From e16a162bc1a9f396ad9cc572dbba4420c3b05e3e Mon Sep 17 00:00:00 2001 From: Nikolas Sapalidis <245706417+nikolas-sapa@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:07:29 +0300 Subject: [PATCH] fix(core-internal): release ReadBuffer backing allocation after the last message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readMessage() advanced the buffer with subarray(index + 1). When the newline was the final byte, that returned an empty view which still referenced the backing allocation of the consumed input — keeping it alive until the next append, and forcing that append through Buffer.concat even though no bytes were buffered. Drop the buffer entirely when the remainder is empty: append() and clear() already handle _buffer === undefined. Fixes #2536 --- .changeset/readbuffer-release-consumed.md | 6 ++++ packages/core-internal/src/shared/stdio.ts | 8 ++++- .../core-internal/test/shared/stdio.test.ts | 31 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 .changeset/readbuffer-release-consumed.md 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();