From bb8cd2a6a85686791e83cd73819b972d4839c309 Mon Sep 17 00:00:00 2001 From: abhirupone Date: Sat, 29 Aug 2026 21:24:04 +0530 Subject: [PATCH] zlib: prevent oversized ArrayBuffer retention in one-shot methods When one-shot convenience methods (zlib.gzip(), zlib.deflate(), zlib.brotliCompress(), zlib.zstdCompress(), etc.) complete with a single output chunk, zlibBufferOnEnd and processChunkSync returned bufs[0], which was a sub-slice of the 16 KB default _outBuffer. For small outputs, this retained the full 16,384-byte backing ArrayBuffer. Because this.close() synchronously frees the native C++ context and decrements the external memory accounter back to 0, V8 does not observe external memory pressure or significant JS heap growth. In long-running processes with comfortable heaps, dead ArrayBuffer allocations accumulated until the process ran out of memory. This change trims single-chunk one-shot results to exact size with Buffer.from() when the chunk is smaller than its backing ArrayBuffer. Fixes: https://github.com/nodejs/node/issues/65600 Signed-off-by: Abhirup Karmakar --- lib/zlib.js | 8 ++- test/parallel/test-zlib-one-shot-memory.js | 81 ++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 test/parallel/test-zlib-one-shot-memory.js diff --git a/lib/zlib.js b/lib/zlib.js index 3e6986bf8127..5a38e39ea7eb 100644 --- a/lib/zlib.js +++ b/lib/zlib.js @@ -167,7 +167,9 @@ function zlibBufferOnEnd() { buf = new FastBuffer(); } else { const bufs = this.buffers; - buf = (bufs.length === 1 ? bufs[0] : Buffer.concat(bufs, this.nread)); + buf = (bufs.length === 1 ? + (bufs[0].byteLength === bufs[0].buffer.byteLength ? bufs[0] : Buffer.from(bufs[0])) : + Buffer.concat(bufs, this.nread)); } this.close(); if (this._info) @@ -497,7 +499,9 @@ function processChunkSync(self, chunk, flushFlag) { if (nread === 0) return new FastBuffer(); - return (buffers.length === 1 ? buffers[0] : Buffer.concat(buffers, nread)); + return (buffers.length === 1 ? + (buffers[0].byteLength === buffers[0].buffer.byteLength ? buffers[0] : Buffer.from(buffers[0])) : + Buffer.concat(buffers, nread)); } function processChunk(self, chunk, flushFlag, cb) { diff --git a/test/parallel/test-zlib-one-shot-memory.js b/test/parallel/test-zlib-one-shot-memory.js new file mode 100644 index 000000000000..6f67732b4016 --- /dev/null +++ b/test/parallel/test-zlib-one-shot-memory.js @@ -0,0 +1,81 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const zlib = require('zlib'); + +// Test that one-shot convenience methods (async and sync) do not retain +// oversized unpooled backing ArrayBuffers (such as the default chunkSize) +// when output size is small. + +const smallPayload = Buffer.from('hello world'.repeat(10)); +const chunkSize = 128 * 1024; + +// Test async and sync compress convenience methods +for (const method of [ + 'gzip', + 'deflate', + 'deflateRaw', + 'brotliCompress', + 'zstdCompress', +]) { + zlib[method](smallPayload, { chunkSize }, common.mustSucceed((buf) => { + assert.ok(Buffer.isBuffer(buf)); + + // The backing ArrayBuffer should not retain the 128KB unpooled chunk + assert.ok( + buf.buffer.byteLength < chunkSize, + `${method} result backing ArrayBuffer should not retain chunkSize padding` + ); + assert.ok( + buf.buffer.byteLength <= Buffer.poolSize, + `${method} result backing ArrayBuffer should not exceed Buffer.poolSize` + ); + })); + + // Test sync convenience method + const syncMethod = `${method}Sync`; + const syncResult = zlib[syncMethod](smallPayload, { chunkSize }); + assert.ok(Buffer.isBuffer(syncResult)); + assert.ok( + syncResult.buffer.byteLength < chunkSize, + `${syncMethod} result backing ArrayBuffer should not retain chunkSize padding` + ); + assert.ok( + syncResult.buffer.byteLength <= Buffer.poolSize, + `${syncMethod} result backing ArrayBuffer should not exceed Buffer.poolSize` + ); +} + +// Test decompress convenience methods +zlib.gzip(smallPayload, common.mustSucceed((compressed) => { + zlib.gunzip(compressed, { chunkSize }, common.mustSucceed((decompressed) => { + assert.strictEqual(decompressed.toString(), smallPayload.toString()); + assert.ok( + decompressed.buffer.byteLength < chunkSize, + 'gunzip result backing ArrayBuffer should not retain chunkSize padding' + ); + assert.ok( + decompressed.buffer.byteLength <= Buffer.poolSize, + 'gunzip result backing ArrayBuffer should not exceed Buffer.poolSize' + ); + })); + + const syncDecompressed = zlib.gunzipSync(compressed, { chunkSize }); + assert.strictEqual(syncDecompressed.toString(), smallPayload.toString()); + assert.ok( + syncDecompressed.buffer.byteLength < chunkSize, + 'gunzipSync result backing ArrayBuffer should not retain chunkSize padding' + ); + assert.ok( + syncDecompressed.buffer.byteLength <= Buffer.poolSize, + 'gunzipSync result backing ArrayBuffer should not exceed Buffer.poolSize' + ); +})); + +// Test error path on invalid data +{ + const invalidGzip = Buffer.from([0x1f, 0x8b, 0x00, 0x00]); + zlib.gunzip(invalidGzip, { chunkSize }, common.mustCall((err) => { + assert.ok(err); + })); +}