From 243905ae417a1aca330f9796f7173e92b520b1b6 Mon Sep 17 00:00:00 2001 From: semimikoh Date: Thu, 9 Jul 2026 15:31:10 +0900 Subject: [PATCH 1/2] buffer: fix Blob.stream() leaking source buffer Blob.prototype.stream() registered a wakeup callback on the underlying source's start() and never released it. The strong Reader::wakeup_ handle kept the reader -- and through it the blob's DataQueue and backing store -- reachable as a GC root, so the source buffer leaked on every stream() call. On Node 26+, streaming a 1 MiB blob 300 times retained ~300 MiB in process.memoryUsage().arrayBuffers while the V8 heap stayed small. Register the wakeup lazily in pull() and clear it on every terminal or idle path (EOS, error, cancel, backpressure), mirroring the cleanup already done by the async iterator path. The strong handle now only lives while a pull is in flight, so the reader and its backing store become collectable once the stream finishes, errors, is cancelled, or goes idle under backpressure. Fixes: https://github.com/nodejs/node/issues/63574 Signed-off-by: semimikoh PR-URL: https://github.com/nodejs/node/pull/63577 Fixes: https://github.com/nodejs/node/issues/63574 Reviewed-By: James M Snell --- lib/internal/blob.js | 21 +++++++++-- test/parallel/test-blob-stream-gc.js | 56 ++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 test/parallel/test-blob-stream-gc.js diff --git a/lib/internal/blob.js b/lib/internal/blob.js index dfaa24b79db5ab..ad84312683d010 100644 --- a/lib/internal/blob.js +++ b/lib/internal/blob.js @@ -473,30 +473,40 @@ function createBlobReaderStream(reader) { // There really should only be one read at a time so using an // array here is purely defensive. this.pendingPulls = []; - // Register a wakeup callback that the C++ side can invoke + // Lazily register a wakeup callback that the C++ side can invoke // when new data is available after a STATUS_BLOCK. - reader.setWakeup(() => { + this.wakeup = () => { if (this.pendingPulls.length > 0) { this.readNext(c); } - }); + }; }, pull(c) { const { promise, resolve, reject } = PromiseWithResolvers(); + if (this.pendingPulls.length === 0) { + reader.setWakeup(this.wakeup); + } this.pendingPulls.push({ resolve, reject }); this.readNext(c); return promise; }, + clearWakeupIfIdle() { + if (this.pendingPulls.length === 0) { + reader.setWakeup(undefined); + } + }, readNext(c) { reader.pull((status, buffer) => { // If pendingPulls is empty here, the stream had to have // been canceled, and we don't really care about the result. // We can simply exit. if (this.pendingPulls.length === 0) { + reader.setWakeup(undefined); return; } if (status === 0) { // EOS + reader.setWakeup(undefined); c.close(); // This is to signal the end for byob readers // see https://streams.spec.whatwg.org/#example-rbs-pull @@ -508,6 +518,7 @@ function createBlobReaderStream(reader) { // The read could fail for many different reasons when reading // from a non-memory resident blob part (e.g. file-backed blob). // The error details the system error code. + reader.setWakeup(undefined); const error = lazyDOMException('The blob could not be read', 'NotReadableError'); @@ -517,7 +528,7 @@ function createBlobReaderStream(reader) { return; } else if (status === 2) { // STATUS_BLOCK: No data available yet. The wakeup callback - // registered in start() will re-invoke readNext when data + // registered in pull() will re-invoke readNext when data // arrives. return; } @@ -537,6 +548,7 @@ function createBlobReaderStream(reader) { if (this.pendingPulls.length !== 0) { const pending = this.pendingPulls.shift(); pending.resolve(); + this.clearWakeupIfIdle(); } return; } @@ -545,6 +557,7 @@ function createBlobReaderStream(reader) { }); }, cancel(reason) { + reader.setWakeup(undefined); // Reject any currently pending pulls here. for (const pending of this.pendingPulls) { pending.reject(reason); diff --git a/test/parallel/test-blob-stream-gc.js b/test/parallel/test-blob-stream-gc.js new file mode 100644 index 00000000000000..24b2f15efc1265 --- /dev/null +++ b/test/parallel/test-blob-stream-gc.js @@ -0,0 +1,56 @@ +// Flags: --expose-gc --no-concurrent-array-buffer-sweeping +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { setImmediate: setImmediatePromise } = require('timers/promises'); + +const MiB = 1024 * 1024; +const iterations = 64; +const maxRetained = 16 * MiB; + +async function collectArrayBuffers() { + for (let i = 0; i < 3; i++) { + global.gc(); + await setImmediatePromise(); + } +} + +async function assertNoBlobStreamRetention(name, fn) { + const buffer = Buffer.alloc(MiB); + + await collectArrayBuffers(); + const before = process.memoryUsage().arrayBuffers; + + for (let i = 0; i < iterations; i++) { + await fn(buffer); + } + + await collectArrayBuffers(); + const retained = process.memoryUsage().arrayBuffers - before; + + assert( + retained < maxRetained, + `${name} retained ${retained} bytes in arrayBuffers`, + ); +} + +(async () => { + await assertNoBlobStreamRetention('unused Blob streams', + common.mustCall(async (buffer) => { + new Blob([buffer]).stream(); + }, iterations)); + + await assertNoBlobStreamRetention('cancelled Blob streams', + common.mustCall(async (buffer) => { + await new Blob([buffer]).stream() + .cancel(); + }, iterations)); + + await assertNoBlobStreamRetention('drained Blob streams', + common.mustCall(async (buffer) => { + await new Response( + new Blob([buffer]).stream(), + ).arrayBuffer(); + }, iterations)); +})().then(common.mustCall()); From 70361998cbcacd8ff55d112f7356b7f84f6da64a Mon Sep 17 00:00:00 2001 From: Kirill Saied Date: Thu, 9 Jul 2026 08:31:24 +0200 Subject: [PATCH 2/2] test: unmark flaky SEA snapshot tests on Windows Signed-off-by: Kirill Saied PR-URL: https://github.com/nodejs/node/pull/64317 Reviewed-By: Joyee Cheung Reviewed-By: Luigi Pinca --- test/sea/sea.status | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/sea/sea.status b/test/sea/sea.status index 3e521658c46dff..9dc3a376bbc8be 100644 --- a/test/sea/sea.status +++ b/test/sea/sea.status @@ -26,11 +26,6 @@ test-build-sea*: SKIP test-single-executable-application*: PASS, FLAKY test-build-sea*: PASS, FLAKY -[$system==win32] -# https://github.com/nodejs/node/issues/49630 -test-single-executable-application-snapshot: PASS, FLAKY -test-single-executable-application-snapshot-and-code-cache: PASS, FLAKY - [$system==solaris || $system==aix] # Cannot compile LIEF on these platforms test-build-sea*: SKIP