Memory leak: Buffer.slice()/subarray() in MCP IPC creates retained views instead of copies
Summary
Buffer.slice() and Buffer.subarray() in MCP stdio IPC handlers create views (not copies) that retain the entire underlying ArrayBuffer. This causes monotonic memory growth proportional to MCP server throughput.
Analysis
Two hot paths in the Bun-compiled claude.exe binary (v2.1.109) use view-creating operations on Buffers:
1. Bridge socket MCP handler (class vJ$)
// .bun section offset ~174310
socket.on("data", (_) => {
this.responseBuffer = Buffer.concat([this.responseBuffer, _]);
while (this.responseBuffer.length >= 4) {
let f = this.responseBuffer.readUInt32LE(0);
if (this.responseBuffer.length < 4 + f) break;
let A = this.responseBuffer.slice(4, 4 + f); // VIEW - retains entire buffer
this.responseBuffer = this.responseBuffer.slice(4 + f); // VIEW - retains entire buffer
// ... JSON.parse(A.toString("utf-8")) ...
}
});
Buffer.slice() returns a view sharing the same ArrayBuffer. The parsed message A holds a reference to the entire concatenated buffer. Even after this.responseBuffer is reassigned to a new slice, the original memory cannot be GC'd until all slice references (including A passed to JSON.parse internals) are released.
2. ReadBuffer class (pgH) for MCP JSON-RPC line protocol
// .bun section offset ~708947
class pgH {
append(H) { this._buffer = this._buffer ? Buffer.concat([this._buffer, H]) : H }
readMessage() {
if (!this._buffer) return null;
let H = this._buffer.indexOf("\n");
if (H === -1) return null;
let $ = this._buffer.toString("utf8", 0, H).replace(/\r$/, "");
return this._buffer = this._buffer.subarray(H + 1), AX7($); // VIEW
}
}
subarray() also creates a view. Every readMessage() call leaves the old buffer retained.
Impact
Each active MCP server connection has both a bridge socket and ReadBuffer. With N MCP servers (typically 3-7), retained buffer chains grow proportionally to total bytes received. For tool calls returning large payloads (file contents, search results), individual retained buffers can be megabytes.
Over a multi-hour session with heavy tool use, this is a likely contributor to the 5-10 GB private memory growth observed in #42169, #33735, #47711.
Fix
Replace view operations with copy operations:
// Bridge socket
let A = Buffer.from(this.responseBuffer.slice(4, 4 + f)); // copy
this.responseBuffer = Buffer.from(this.responseBuffer.slice(4 + f)); // copy
// ReadBuffer
this._buffer = Buffer.from(this._buffer.subarray(H + 1)); // copy
The copy cost is O(remaining_buffer) per message but eliminates the retention chain. Alternatively, use a ring buffer or pre-allocated pool.
Environment
- claude.exe v2.1.109 (Bun binary)
- Analysis method: PE section extraction + pattern search on
.bunJS bundle - Affects all platforms (Buffer.slice/subarray semantics are the same in Node.js and Bun)
Related Issues
- #42169 (Windows resource exhaustion)
- #33735 (18 GB private memory)
- #47711 (930 MB/min growth)
- #33589 (BytesInternalReadableStreamSource — separate issue in fetch layer)
- #33447 (API response body retention — related but different path)
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗