Skip to content

Close listener leak in fs/promises createReadStream #64214

Description

@davidje13

Version

v24.18.0

Platform

Darwin *.local 24.6.0 Darwin Kernel Version 24.6.0: Tue Apr 21 20:19:12 PDT 2026; root:xnu-11417.140.69.710.16~1/RELEASE_ARM64_T6041 arm64

Subsystem

fs/promises

What steps will reproduce the bug?

Here is a silly example which prints the first 11 bytes of a file by reading them separately from disk:

import { buffer } from 'node:stream/consumers';
import { open } from 'node:fs/promises';

const f = await open('my-file.txt');
for (let i = 0; i < 11; i++) {
  const byte = await buffer(f.createReadStream({ start: i, end: i, autoClose: false }));
  console.log(`byte ${i} is ${byte[0]}. Close listeners = ${f.listeners('close').length}`);
}

Running it (pointing at any file containing at least 11 bytes) will demonstrate the issue (output below)

How often does it reproduce? Is there a required condition?

Every call to createReadStream adds a close listener to the file handle, and I have not found a way to remove this listener. If called at least 11 times, it will trigger Node.js' built-in event leak detection warning. The threshold can be increased to avoid this warning, but the leak remains.

What is the expected behavior? Why is that the expected behavior?

Once a stream is consumed, the close event listener it attaches to the FileHandle should be removed, even when autoClose is false, so that applications can read arbitrarily many ranges from a file.

What do you see instead?

byte 0 is 0. Close listeners = 1
byte 1 is 0. Close listeners = 2
byte 2 is 0. Close listeners = 3
byte 3 is 0. Close listeners = 4
byte 4 is 0. Close listeners = 5
byte 5 is 0. Close listeners = 6
byte 6 is 0. Close listeners = 7
byte 7 is 0. Close listeners = 8
byte 8 is 0. Close listeners = 9
byte 9 is 0. Close listeners = 10
(node:70360) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 close listeners added to [FileHandle]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
    at genericNodeError (node:internal/errors:985:15)
    at wrappedFn (node:internal/errors:539:14)
    at _addListener (node:events:590:17)
    at FileHandle.addListener (node:events:608:10)
    at importFd (node:internal/fs/streams:156:16)
    at new ReadStream (node:internal/fs/streams:189:30)
    at FileHandle.createReadStream (node:internal/fs/promises:363:12)
    at file:///[...]/test.mts:6:15
    at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
byte 10 is 0. Close listeners = 11

Additional information

This can be worked around in user-space with a hacky approach:

function createSafeReadStream(handle, options) {
  const before = handle.listeners('close').length;
  const stream = handle.createReadStream(options);
  const after = handle.listeners('close');
  if (after.length > before) {
    const listener = after[after.length - 1];
    const teardown = () => {
      handle.off('close', listener);
      stream.off('end', teardown);
      stream.off('error', teardown);
    };
    stream.once('end', teardown);
    stream.once('error', teardown);
  }
  return stream;
}

// ...

for (let i = 0; i < 11; i++) {
  const byte = await buffer(createSafeReadStream(f, { start: i, end: i, autoClose: false }));
  console.log(`byte ${i} is ${byte[0]}. Close listeners = ${f.listeners('close').length}`);
}

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