Skip to content

storage 8.0.1: MaxListenersExceededWarning (11 error/close listeners on PassThrough) on every createReadStream() #9185

Open

Description

@glenn-jocher

Package: @google-cloud/storage (handwritten/storage)

Environment

  • @google-cloud/storage 8.0.1 (teeny-request 11.0.1, retry-request 9.0.1)
  • Node 26.5.1 (also on Vercel's Node runtime)
  • Does not occur with 8.0.0 (teeny-request 9.0.0), same code — 8.0.1's change vs 8.0.0 is that dependency bump (teeny-request 11 moved to node-fetch 3).

Steps to reproduce

import { Storage } from "@google-cloud/storage";
const file = new Storage().bucket("<bucket>").file("<~3 MB object>");
let n = 0;
for await (const chunk of file.createReadStream()) n += chunk.length;

Run with node --trace-warnings.

Actual

Every streamed download emits both:

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.

Activity

  1. PierBover commented on Aug 22, 2026

    @PierBover

    Also seeing this with 8.0.1 and Node 26.

    For now the workaround is to increase the number of available listeners:

    import { EventEmitter } from 'node:events';
    EventEmitter.defaultMaxListeners = 30;
  2. Jojoooo1 commented on Aug 31, 2026

    @Jojoooo1

    Same here.

  3. Mattpat3 commented on Sep 8, 2026

    @Mattpat3

    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:

    1. destroy responseStream when requestStream closes early;
    2. immediately destroy the fetch body if fetch resolves after requestStream was already destroyed; and
    3. 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.

  4. 7hokerz commented on Sep 27, 2026

    @7hokerz
    Contributor

    Also reproduced on Node 24 with @google-cloud/storage@8.2.0 / teeny-request@11.0.1.

  5. urbullet commented on Sep 30, 2026

    @urbullet

    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:

    import assert from 'node:assert/strict';
    import { Storage } from '@google-cloud/storage';
    const storage = new Storage(); // use your normal GCS credentials
    const file = storage.bucket(process.env.BUCKET).file('listener-repro/disposable-object');
    const bytes = Buffer.alloc(2 * 1024 * 1024, 0x61);
    await file.save(bytes, { resumable: false });
    try {
      await Promise.all(Array.from({ length: 4 }, async () => {
        let count = 0;
        for await (const chunk of file.createReadStream()) {
          assert.ok(chunk.every(byte => byte === 0x61));
          count += chunk.length;
        }
        assert.equal(count, bytes.length);
      }));
    } finally {
      await file.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.

  6. sgidevnl commented on Oct 10, 2026

    @sgidevnl

    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:

    node --trace-warnings repro.mjs
    DOWNLOADS=12 node --trace-warnings repro.mjs

    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.
    import assert from "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);
    const bytes = Buffer.alloc(2_369_405, 0x5a);
    const crc = new CRC32C();
    crc.update(bytes);
    let warnings = 0;
    const countWarning = warning => {
      if (warning.name === "MaxListenersExceededWarning") warnings++;
    };
    process.on("warning", countWarning); // Count only; Node still prints every warning.
    const server = 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);
    });
    await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
    const storage = new Storage({
      apiEndpoint: `http://127.0.0.1:${server.address().port}`,
      projectId: "offline-listener-reproducer",
      retryOptions: { autoRetry: false },
    });
    const file = storage.bucket("fixture").file("synthetic.bin");
    const downloads = Number(process.env.DOWNLOADS ?? 1);
    try {
      await Promise.all(Array.from({ length: downloads }, async () => {
        let length = 0;
        const received = new CRC32C();
        for await (const chunk of file.createReadStream()) {
          length += chunk.length;
          received.update(chunk);
        }
        assert.equal(length, bytes.length);
        assert.equal(received.toString(), crc.toString());
      }));
      await setImmediate(); // 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();
      await new Promise(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
    
  7. zaitsman commented on Oct 15, 2026

    @zaitsman

    Can confirm this happens for is me on

    @google-cloud/storage 8.4.0

    with repro very similar to @sgidevnl reported listing (and obviously in a larger application working with Google Cloud Storage)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions