Hacktoberfest 2026:維護者為十月標記出來的 issue,仍然開放、適合新手。 瀏覽 Hacktoberfest issue

`stream.pipeline()` leaks file descriptors when it throws synchronously (ERR_STREAM_UNABLE_TO_PIPE)

未關閉
#65,063 0 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視

還沒有人認領這個 Issue。

評估

難度
3/5
預估耗時
1-2 天
新手友好度
68/100
Issue 類型
缺陷
描述清晰度
描述清楚
活躍度
冷清
技術堆疊
javascript
領域
backend

研究方向

從 lib/internal/streams/pipeline.js 中的 pipelineImpl() 和 finishImpl() 開始,然後查看 doc/api/stream.md 中 stream.pipeline() 的保證。重現 issue 中同步的 ERR_STREAM_UNABLE_TO_PIPE 和 ERR_INVALID_RETURN_VALUE 情況,並將它們與非同步的 ENOENT 對照情況進行比較。完成的標準是:已採用的串流被銷毀、檔案描述元被釋放,且呼叫端的 AbortSignal listener 被移除。

由索引模型根據 Issue 內容生成。

描述

Version

v24.11.1, and main (735a09f999a)

Platform

Reproduced on Windows 11 x64; the code path is platform independent.

Subsystem

stream

What steps will reproduce the bug?

stream.pipeline() wires the streams together in a loop, and that loop can throw synchronously. The most common case is ERR_STREAM_UNABLE_TO_PIPE, raised when the destination is already closed or destroyed. When it throws, every stream pipeline() had already taken ownership of is left undestroyed, so its resources leak. For a fs.ReadStream source that means a leaked file descriptor.

The trigger is an ordinary production condition: piping to a destination that has already gone away, e.g. pipeline(fs.createReadStream(file), res) after the HTTP client disconnected.

import { pipeline, PassThrough, Writable } from 'node:stream';
import { pipeline as pipelinePromise } from 'node:stream/promises';
import { once, getEventListeners } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pipeline-leak-'));
const file = path.join(tmp, 'data.bin');
fs.writeFileSync(file, Buffer.alloc(4096, 'x'));

const openStream = async () => {
  const rs = fs.createReadStream(file);
  await once(rs, 'open');          // ensure the fd is really allocated
  return rs;
};
const deadWritable = () => {
  const w = new Writable({ write(c, e, cb) { cb(); } });
  w.destroy();                     // destination already gone
  return w;
};

// 1. callback form
{
  const sources = [];
  for (let i = 0; i < 50; i++) {
    const rs = await openStream();
    sources.push(rs);
    try {
      pipeline(rs, new PassThrough(), deadWritable(), () => {});
    } catch (err) {
      if (i === 0) console.log('callback form throws :', err.code);
    }
  }
  await new Promise((r) => setTimeout(r, 150));
  console.log('  sources undestroyed :', sources.filter((s) => !s.destroyed).length, '/ 50  (expected 0)');
  console.log('  fds still open      :', sources.filter((s) => s.fd != null).length, '/ 50  (expected 0)');
  for (const s of sources) s.destroy();
}

// 2. promise form, plus the caller's AbortSignal
{
  const ac = new AbortController();  // long-lived, e.g. a server shutdown signal
  const sources = [];
  for (let i = 0; i < 50; i++) {
    const rs = await openStream();
    sources.push(rs);
    try {
      await pipelinePromise(rs, new PassThrough(), deadWritable(), { signal: ac.signal });
    } catch (err) {
      if (i === 0) console.log('promise form rejects :', err.code);
    }
  }
  await new Promise((r) => setTimeout(r, 150));
  console.log('  sources undestroyed :', sources.filter((s) => !s.destroyed).length, '/ 50  (expected 0)');
  console.log('  fds still open      :', sources.filter((s) => s.fd != null).length, '/ 50  (expected 0)');
  console.log('  abort listeners     :', getEventListeners(ac.signal, 'abort').length, '/ 50  (expected 0)');
  for (const s of sources) s.destroy();
}

// 3. control: an ordinary asynchronous failure does clean up correctly
{
  const rs = fs.createReadStream(path.join(tmp, 'nope'));
  const mid = new PassThrough();
  await new Promise((resolve) => pipeline(rs, mid, new PassThrough(), () => resolve()));
  console.log('control (ENOENT)     : rs.destroyed =', rs.destroyed, '| mid.destroyed =', mid.destroyed, ' (both expected true)');
}

fs.rmSync(tmp, { recursive: true, force: true });
How often does it reproduce? Is there a required condition?

Every time. The only condition is that pipeline() throws while wiring the streams up, after at least one stream has already been wired.

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

All the streams should be destroyed and the file descriptors released, and the listener added to the caller's AbortSignal should be removed.

doc/api/stream.md states:

stream.pipeline() closes all the streams when an error is raised.

and

stream.pipeline() will call stream.destroy(err) on all streams except: Readable streams which have emitted 'end' or 'close'; Writable streams which have emitted 'finish' or 'close'.

The sources here emitted none of those events. The control case in the reproduction shows that an asynchronous pipeline error does destroy everything, so the two paths disagree.

What do you see instead?
callback form throws : ERR_STREAM_UNABLE_TO_PIPE
  sources undestroyed : 50 / 50  (expected 0)
  fds still open      : 50 / 50  (expected 0)
promise form rejects : ERR_STREAM_UNABLE_TO_PIPE
  sources undestroyed : 50 / 50  (expected 0)
  fds still open      : 50 / 50  (expected 0)
  abort listeners     : 50 / 50  (expected 0)
control (ENOENT)     : rs.destroyed = true | mid.destroyed = true  (both expected true)
Additional information

The wiring loop in pipelineImpl() (lib/internal/streams/pipeline.js) pushes a destroy function into destroys for each stream it adopts, and finishImpl() is the only code that drains destroys, disposes the AbortSignal listener and calls ac.abort().

The loop is not wrapped in try/finally, and it can throw at six places:

  • ERR_STREAM_UNABLE_TO_PIPE when the next stream is already closed or destroyed
  • ERR_INVALID_RETURN_VALUE (x3) when a transform function returns something that is not iterable
  • ERR_INVALID_ARG_TYPE (x2) when a value cannot be piped into the next stream

When any of these fire, finishImpl() never runs, so nothing in destroys is ever called.

The ERR_INVALID_RETURN_VALUE path leaks in exactly the same way:

const source = fs.createReadStream(file);
pipeline(source, () => 42, new PassThrough(), () => {});  // throws
// source is left open

I have a fix and will open a PR shortly.

主要語言
JavaScript
星號
122k
分支
37.4k
平均合併
4 天 3 小時
30 天內合併 PR
279

貢獻指南

開啟貢獻指南

從這裡開始

  1. 先讀完整個 Issue,再讀專案的貢獻指南。
  2. 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
  3. Fork 儲存庫,在一個分支上完成修改。
  4. 送出 Pull Request,並在描述裡引用這個 Issue 編號。

nodejs/node 的其他 Issue

查看 nodejs/node 的全部 Issue

相似的 Issue

更多 JavaScript Issue

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。