nodejs / nodejs/node

Close listener leak in fs/promises `createReadStream`

Open
#64,214 6 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

fs
Dominant language
JavaScript
Stars
122k
Forks
37.3k
Avg merge
4d 2h
Merged PRs (30d)
283

Description

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}`);
}

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in lib/internal/fs/streams.js at the listener added by createReadStream, then trace FileHandle.createReadStream in lib/internal/fs/promises. Reproduce the issue with the provided repeated-range example and add or update regression coverage where the fs/promises stream behavior is tested. Done means consumed streams no longer accumulate close listeners when autoClose is false.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, node.js
Domain
backend, operating-systems
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.