You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
storage 8.0.1: MaxListenersExceededWarning (11 error/close listeners on PassThrough) on every createReadStream() #9185
MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 error listeners added to [PassThrough]. MaxListeners is 10.
at pipelineImpl (node:internal/streams/pipeline:286:14)
at pipeline (node:internal/streams/pipeline:183:10)
at PassThrough.<anonymous> (teeny-request/build/src/index.js:194:43)
MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 close listeners added to [PassThrough]. MaxListeners is 10.
at eos (node:internal/streams/end-of-stream:302:10)
at pipe (node:internal/streams/pipeline:447:3)
at pipelineImpl (node:internal/streams/pipeline:374:25)
at PassThrough.<anonymous> (teeny-request/build/src/index.js:194:43)
The emitter is the node-fetch 3 response body PassThrough (teeny-request's responseStream in stream mode). Its error listeners when the warning fires are node-fetch's FetchError wrapper, three onError/eos pairs from node-fetch's own internal pipeline calls, teeny-request's (err) => requestStream.emit('error', err) forwarder (index.js:201), and the listeners stream.pipeline(responseStream, requestStream) adds at index.js:194 — the count is reached by library wiring over node-fetch 3's body, not by the caller. (teeny-request's own repo is archived, so reporting here.)
Expected
No warning for a single stream consumer, as with 8.0.0.
Impact
Two warnings per download on any service streaming from GCS, which floods runtime-error dashboards. We have pinned to 8.0.0 meanwhile.
I reproduced this on Node 24 with @google-cloud/storage@8.0.1 / teeny-request@11.0.1 at Node's default listener threshold. The warning occurs inside teeny-request before caller code can access the response stream.
A real-bucket soak that repeatedly runs 12 concurrent generation-pinned downloads for 20 iterations reproduces it for both full reads and byte ranges. A cancellation-safe local transport patch removes the warnings across:
concurrent full and Range reads
downstream/client aborts
upstream failures after the pinned object is deleted
The narrow change is to replace the nested pipeline(responseStream, requestStream, ...) bridge with responseStream.pipe(requestStream), but plain .pipe() is not enough. It must also:
destroy responseStream when requestStream closes early;
immediately destroy the fetch body if fetch resolves after requestStream was already destroyed; and
forward response-body errors with requestStream.destroy(err).
Those semantics keep downstream cancellation from leaving the fetch body/socket alive while avoiding the extra listeners added by a nested pipeline. The soak remains at the default max-listener threshold rather than masking the regression by raising it.
Since teeny-request is archived and 11.0.1 is still its latest release, this likely needs a Storage transport replacement or an equivalent fix carried in an official Storage release.
Additional minimal reproduction on Node 24.13.0 with @google-cloud/storage 8.2.0 and teeny-request 11.0.1 (default listener threshold):
the following uses one disposable 2 MiB object and four parallel reads; no application route or custom stream wrapper is involved:
importassertfrom'node:assert/strict';import{Storage}from'@google-cloud/storage';conststorage=newStorage();// use your normal GCS credentialsconstfile=storage.bucket(process.env.BUCKET).file('listener-repro/disposable-object');constbytes=Buffer.alloc(2*1024*1024,0x61);awaitfile.save(bytes,{resumable: false});try{awaitPromise.all(Array.from({length: 4},async()=>{letcount=0;forawait(constchunkoffile.createReadStream()){assert.ok(chunk.every(byte=>byte===0x61));count+=chunk.length;}assert.equal(count,bytes.length);}));}finally{awaitfile.delete({ignoreNotFound: true});}
Run with node --trace-warnings. I observed 8 warnings for 4 complete, byte-correct downloads (one each for 11 error and 11 close listeners per PassThrough); all warning stacks terminate at teeny-request/build/src/index.js:194 inside its nested pipeline(responseStream, requestStream). No max-listener limit changes or dependency patches were applied. Our concurrent provider-backed application-route downloads show the same warnings even though cancelled reads close their sources correctly. A supported transport fix should preserve upstream-error propagation and prompt source cancellation, not just swap pipeline for bare pipe (as noted above). We will keep the existing safe streaming path pending a published fix and will verify parallel bytes, cancellation and warning-free traces before upgrading.
Credential-free reproduction and pipeline-ownership fix
Additional reproduction on Node 24.13.0, @google-cloud/storage 8.3.0, teeny-request 11.0.1, and Node's unchanged default listener threshold of 10.
This uses only a loopback HTTP server and synthetic bytes: no bucket, credentials,
application, logger, or cloud writes.
Run the reproducer below as repro.mjs in a project with the unpatched @google-cloud/storage@8.3.0 installed:
Baseline: one complete 2,369,405-byte download produces two warnings (11 error
and 11 close listeners on the node-fetch response body). Warning stacks point
to teeny-request's response callback installing pipeline(responseStream, requestStream) after Storage has installed its CRC32c pipeline on the same
response. The reproducer independently checks full-byte CRC32c, while also
exercising the SDK's default CRC32c validation.
The latest registry versions checked are Storage 8.4.0 and teeny-request 11.0.1.
Storage 8.4.0 has the same read-stream implementation and teeny-request constraint,
so there is no published ownership fix to upgrade to. The listener-limit change
in PR #9254 suppresses the symptom rather than removing the overlapping owner.
Proposed narrow bridge fix
Replace only the streaming teeny-request bridge with backpressured .pipe(),
and retain explicit lifecycle ownership:
Destroy the fetch body when the request stream closes.
Destroy a fetch body arriving after the consumer already cancelled.
Propagate body errors via requestStream.destroy(originalError).
Propagate premature body closure without an error as ERR_STREAM_PREMATURE_CLOSE.
Keep the body error handler for late errors; never remove node-fetch or SDK
listeners, disable validation, filter warnings, or raise listener limits.
Leave callback/multipart paths and storage authentication unchanged.
An exact version-pinned pnpm patch of the compiled package is included below.
It is a local candidate, not an official supported upstream release.
Verification of the candidate
The patched reproducer completed 12 parallel, byte/CRC32c-correct downloads
with zero warnings at the default threshold.
The real-SDK suite passed parallel full-byte reads, default CRC32c validation,
rejection of a wrong CRC32c, HTTP 503 before headers, a truncated upstream, and
client cancellation closing the upstream socket. Direct transport tests cover
both reading-before-response and response-before-reading, backpressured full
reads, preservation of the original late body error, premature body close,
socket failure before/after headers, downstream cancellation, and a body arriving
after cancellation.
Separate logger integration tests in development and production preserved all
14 request completion/abort records with distinct IDs (12 successful real-SDK
downloads plus a pre-header failure and a corrupt partial image). Both storage
errors remained logged, including CONTENT_DOWNLOAD_MISMATCH.
Credential-free retention probes, with independent WeakRefs to every SDK
response rather than relying on warnings to locate streams, also passed:
Transport
Downloads
Concurrency
Warnings
Completed response targets after GC
HTTP
1,001
12
0
0 throughout and after idle
Verified HTTPS
513
8
0
8 during load/idle; 0 after connections close
TLS verification was enabled with an explicitly trusted fixture certificate.
Both probes validated every transfer and included a 12-second idle check.
HTTP array buffers stayed at 2,470,592 bytes. TLS array buffers were approximately
2.56 MB during load/idle and 2.47 MB after connection closure, including the shared
2.37 MB fixture payload. The bounded TLS retention is separate from the
per-download listener warning; this patch does not claim to remove that retaining
path or prove all workloads leak-free.
Please consider an ownership/lifecycle fix instead of listener-limit suppression.
Standalone reproducer
// Credential-free upstream reproducer: only a loopback server and synthetic bytes.importassertfrom"node:assert/strict";import{createServer}from"node:http";import{setImmediate}from"node:timers/promises";import{Storage,CRC32C}from"@google-cloud/storage";import{defaultMaxListeners}from"node:events";assert.equal(defaultMaxListeners,10);constbytes=Buffer.alloc(2_369_405,0x5a);constcrc=newCRC32C();crc.update(bytes);letwarnings=0;constcountWarning=warning=>{if(warning.name==="MaxListenersExceededWarning")warnings++;};process.on("warning",countWarning);// Count only; Node still prints every warning.constserver=createServer((_req,res)=>{res.writeHead(200,{"content-length": bytes.length,"x-goog-hash": `crc32c=${crc.toString()}`,"x-goog-stored-content-encoding": "identity",});res.end(bytes);});awaitnewPromise(resolve=>server.listen(0,"127.0.0.1",resolve));conststorage=newStorage({apiEndpoint: `http://127.0.0.1:${server.address().port}`,projectId: "offline-listener-reproducer",retryOptions: {autoRetry: false},});constfile=storage.bucket("fixture").file("synthetic.bin");constdownloads=Number(process.env.DOWNLOADS??1);try{awaitPromise.all(Array.from({length: downloads},async()=>{letlength=0;constreceived=newCRC32C();forawait(constchunkoffile.createReadStream()){length+=chunk.length;received.update(chunk);}assert.equal(length,bytes.length);assert.equal(received.toString(),crc.toString());}));awaitsetImmediate();// Node emits warnings asynchronously.console.log(JSON.stringify({node: process.version, downloads,bytes: bytes.length, warnings }));if(process.env.EXPECT_WARNINGS!==undefined){assert.equal(warnings,Number(process.env.EXPECT_WARNINGS));}}finally{process.removeListener("warning",countWarning);server.closeAllConnections();awaitnewPromise(resolve=>server.close(resolve));}
Candidate patch
diff --git a/build/src/index.js b/build/src/index.js
index a101736025f90dc036155e99c0e0b432291e0bcb..c4fe22be58e1c7cff33038131cdfeffd65e3a706 100644
--- a/build/src/index.js+++ b/build/src/index.js@@ -185,13 +185,22 @@ function teenyRequest(reqOpts, callback) {
const requestStream = streamEvents(new stream_1.PassThrough());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let responseStream;
+ // This bridge is not a second owner of the fetch body's pipelines.+ // Keep backpressure, but explicitly propagate errors and cancellation.+ requestStream.once('close', () => {+ if (responseStream) {+ responseStream.destroy();+ }+ });
requestStream.once('reading', () => {
if (responseStream) {
- (0, stream_1.pipeline)(responseStream, requestStream, () => { });+ responseStream.pipe(requestStream);
}
else {
requestStream.once('response', () => {
- (0, stream_1.pipeline)(responseStream, requestStream, () => { });+ if (!requestStream.destroyed) {+ responseStream.pipe(requestStream);+ }
});
}
});
@@ -201,13 +210,25 @@ function teenyRequest(reqOpts, callback) {
teenyRequest.stats.requestFinished();
responseStream = res.body;
responseStream.on('error', (err) => {
- requestStream.emit('error', err);+ requestStream.destroy(err);
});
+ responseStream.once('close', () => {+ if (!responseStream.readableEnded && !requestStream.destroyed) {+ const err = new Error('Premature close');+ err.code = 'ERR_STREAM_PREMATURE_CLOSE';+ requestStream.destroy(err);+ }+ });+ // A consumer can cancel while fetch is still waiting for headers.+ if (requestStream.destroyed) {+ responseStream.destroy();+ return;+ }
const response = fetchToRequestResponse(options, res);
requestStream.emit('response', response);
}, err => {
teenyRequest.stats.requestFinished();
- requestStream.emit('error', err);+ requestStream.destroy(err);
});
// fetch doesn't supply the raw HTTP stream, instead it
// returns a PassThrough piped from the HTTP response
Activity
PierBover commentedon Aug 22, 2026
Also seeing this with
8.0.1and Node 26.For now the workaround is to increase the number of available listeners:
Jojoooo1 commentedon Aug 31, 2026
Same here.
Mattpat3 commentedon Sep 8, 2026
I reproduced this on Node 24 with
@google-cloud/storage@8.0.1/teeny-request@11.0.1at Node's default listener threshold. The warning occurs inside teeny-request before caller code can access the response stream.A real-bucket soak that repeatedly runs 12 concurrent generation-pinned downloads for 20 iterations reproduces it for both full reads and byte ranges. A cancellation-safe local transport patch removes the warnings across:
The narrow change is to replace the nested
pipeline(responseStream, requestStream, ...)bridge withresponseStream.pipe(requestStream), but plain.pipe()is not enough. It must also:responseStreamwhenrequestStreamcloses early;requestStreamwas already destroyed; andrequestStream.destroy(err).Those semantics keep downstream cancellation from leaving the fetch body/socket alive while avoiding the extra listeners added by a nested pipeline. The soak remains at the default max-listener threshold rather than masking the regression by raising it.
Since teeny-request is archived and 11.0.1 is still its latest release, this likely needs a Storage transport replacement or an equivalent fix carried in an official Storage release.
7hokerz commentedon Sep 27, 2026
Also reproduced on Node 24 with
@google-cloud/storage@8.2.0/teeny-request@11.0.1.urbullet commentedon Sep 30, 2026
Additional minimal reproduction on Node 24.13.0 with @google-cloud/storage 8.2.0 and teeny-request 11.0.1 (default listener threshold):
the following uses one disposable 2 MiB object and four parallel reads; no application route or custom stream wrapper is involved:
Run with
node --trace-warnings. I observed 8 warnings for 4 complete, byte-correct downloads (one each for 11errorand 11closelisteners per PassThrough); all warning stacks terminate atteeny-request/build/src/index.js:194inside its nestedpipeline(responseStream, requestStream). No max-listener limit changes or dependency patches were applied. Our concurrent provider-backed application-route downloads show the same warnings even though cancelled reads close their sources correctly. A supported transport fix should preserve upstream-error propagation and prompt source cancellation, not just swappipelinefor barepipe(as noted above). We will keep the existing safe streaming path pending a published fix and will verify parallel bytes, cancellation and warning-free traces before upgrading.sgidevnl commentedon Oct 10, 2026
Credential-free reproduction and pipeline-ownership fix
Additional reproduction on Node 24.13.0,
@google-cloud/storage8.3.0,teeny-request11.0.1, and Node's unchanged default listener threshold of 10.This uses only a loopback HTTP server and synthetic bytes: no bucket, credentials,
application, logger, or cloud writes.
Run the reproducer below as
repro.mjsin a project with the unpatched@google-cloud/storage@8.3.0installed:Baseline: one complete 2,369,405-byte download produces two warnings (11
errorand 11
closelisteners on the node-fetch response body). Warning stacks pointto teeny-request's response callback installing
pipeline(responseStream, requestStream)after Storage has installed its CRC32c pipeline on the sameresponse. The reproducer independently checks full-byte CRC32c, while also
exercising the SDK's default CRC32c validation.
The latest registry versions checked are Storage 8.4.0 and teeny-request 11.0.1.
Storage 8.4.0 has the same read-stream implementation and teeny-request constraint,
so there is no published ownership fix to upgrade to. The listener-limit change
in PR #9254 suppresses the symptom rather than removing the overlapping owner.
Proposed narrow bridge fix
Replace only the streaming teeny-request bridge with backpressured
.pipe(),and retain explicit lifecycle ownership:
requestStream.destroy(originalError).ERR_STREAM_PREMATURE_CLOSE.listeners, disable validation, filter warnings, or raise listener limits.
An exact version-pinned pnpm patch of the compiled package is included below.
It is a local candidate, not an official supported upstream release.
Verification of the candidate
The patched reproducer completed 12 parallel, byte/CRC32c-correct downloads
with zero warnings at the default threshold.
The real-SDK suite passed parallel full-byte reads, default CRC32c validation,
rejection of a wrong CRC32c, HTTP 503 before headers, a truncated upstream, and
client cancellation closing the upstream socket. Direct transport tests cover
both reading-before-response and response-before-reading, backpressured full
reads, preservation of the original late body error, premature body close,
socket failure before/after headers, downstream cancellation, and a body arriving
after cancellation.
Separate logger integration tests in development and production preserved all
14 request completion/abort records with distinct IDs (12 successful real-SDK
downloads plus a pre-header failure and a corrupt partial image). Both storage
errors remained logged, including
CONTENT_DOWNLOAD_MISMATCH.Credential-free retention probes, with independent WeakRefs to every SDK
response rather than relying on warnings to locate streams, also passed:
TLS verification was enabled with an explicitly trusted fixture certificate.
Both probes validated every transfer and included a 12-second idle check.
HTTP array buffers stayed at 2,470,592 bytes. TLS array buffers were approximately
2.56 MB during load/idle and 2.47 MB after connection closure, including the shared
2.37 MB fixture payload. The bounded TLS retention is separate from the
per-download listener warning; this patch does not claim to remove that retaining
path or prove all workloads leak-free.
Please consider an ownership/lifecycle fix instead of listener-limit suppression.
Standalone reproducer
Candidate patch
zaitsman commentedon Oct 15, 2026
Can confirm this happens for is me on
with repro very similar to @sgidevnl reported listing (and obviously in a larger application working with Google Cloud Storage)