Skip to content

fs.promises: close leaked FileHandle fds on GC and raise ERR_INVALID_STATE - #33693

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/c77daebe/filehandle-gc-finalizer
Jul 8, 2026
Merged

fs.promises: close leaked FileHandle fds on GC and raise ERR_INVALID_STATE#33693
Jarred-Sumner merged 4 commits into
mainfrom
farm/c77daebe/filehandle-gc-finalizer

Conversation

@robobun

@robobun robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

A FileHandle dropped without close() was never finalized in Bun: the underlying fd stayed open until process exit with no diagnostic. Node.js closes the fd in its native finalizer and, since v25 (DEP0137 end-of-life), raises ERR_INVALID_STATE as an uncaught exception.

Reproduction

import * as fsp from "node:fs/promises";
import * as fs from "node:fs";
const nfds = () => fs.readdirSync("/proc/self/fd").length;
let diags = 0;
process.on("uncaughtException", () => diags++);
const before = nfds();
await (async () => { for (let i = 0; i < 200; i++) await fsp.open(`/tmp/f${i}`, "w"); })();
for (let i = 0; i < 6; i++) { Bun.gc(true); await new Promise(r => setTimeout(r, 40)); }
console.log(`${nfds() - before} fds still open, ${diags} diagnostics`);
fds leaked diagnostics
Node v26.3.0 0 200 (ERR_INVALID_STATE)
Bun before 200 0
Bun after 0 200 (ERR_INVALID_STATE)

Fix

Register each open FileHandle with a FinalizationRegistry keyed on {fd, path}. When collected unclosed, the finalizer:

  1. closes the fd synchronously via the native closeSync binding
  2. throws an Error with code: "ERR_INVALID_STATE" and Node's exact message (including fd number and path) via process.nextTick, so every handle in a GC batch gets processed rather than the first throw aborting the batch

Explicit close(), [Symbol.asyncDispose], [kCloseSync]() (pull/writer autoClose), and [kTransfer]() unregister the handle so no false positives fire. Handles deserialized from a worker transfer are re-registered in [kDeserialize]().

Also fixes three existing tests that were leaking FileHandles and would now be caught by the finalizer.

How did you verify your code works?

  • New test in test/js/node/fs/fs.test.ts spawns a subprocess that drops 50 handles, forces GC, and asserts 0 fds remain open with 50 ERR_INVALID_STATE diagnostics; then opens and properly closes 50 more and asserts 0 false positives.
  • Fails on released Bun (leakedAfterGC: 50, diagCount: 0), passes on this branch.
  • bun bd test test/js/node/fs/fs.test.ts (382 pass), promises.test.js, fs-leak.test.js, bun-file.test.ts, and the FileHandle transfer tests in worker_threads.test.ts all pass.

…STATE

A FileHandle dropped without close() was never finalized: the fd stayed
open until process exit with no diagnostic. Node.js closes the fd in its
native finalizer and (since v25, DEP0137 end-of-life) raises
ERR_INVALID_STATE as an uncaught exception.

Register each open FileHandle with a FinalizationRegistry that closes
the fd and reports the error via process.nextTick when collected
unclosed. Unregister on close()/[kCloseSync]()/[kTransfer](). Handles
deserialized from worker transfer are re-registered in [kDeserialize]().

Also fix three existing tests that were leaking FileHandles.
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:24 PM PT - Jul 7th, 2026

@robobun, your commit 87612ae has some failures in Build #70046 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33693

That installs a local version of the PR into your bun-33693 executable, so you can run:

bun-33693 --bun

@github-actions github-actions Bot added the claude label Jul 7, 2026
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1334158a-c356-4eb6-ae79-8355f6aef852

📥 Commits

Reviewing files that changed from the base of the PR and between d404254 and 87612ae.

📒 Files selected for processing (1)
  • src/js/node/fs.promises.ts

Walkthrough

Adds FinalizationRegistry-based cleanup for leaked FileHandle objects in fs.promises.ts, wires registration and unregistration through the handle lifecycle, and updates tests to cover GC-driven diagnostics and explicit handle disposal.

Changes

FileHandle GC Leak Detection

Layer / File(s) Summary
Finalization registry and GC error path
src/js/node/fs.promises.ts
Adds the finalizer callback that closes leaked fds and schedules ERR_INVALID_STATE, and passes the resolved path from open() into FileHandle.
FileHandle constructor and lifecycle wiring
src/js/node/fs.promises.ts
Extends FileHandle construction to register diagnostic data, unregisters on close/closeSync/transfer, and re-registers on deserialize.
Regression and cleanup tests
test/js/node/fs/fs.test.ts, test/js/bun/util/bun-file.test.ts, test/js/node/fs/promises.test.js, test/cli/install/bun-lock.test.ts, test/cli/install/bun-lockb.test.ts, test/js/node/test/parallel/test-whatwg-readablebytestream.js
Adds a GC regression test for leaked handles and updates existing tests to dispose file handles through close() or using bindings.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: GC-finalized FileHandle cleanup with ERR_INVALID_STATE diagnostics.
Description check ✅ Passed The description includes both required sections and provides a clear implementation summary plus verification details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

bun-lock/bun-lockb tests opened the lockfile without closing it; switch
to await using. test-whatwg-readablebytestream.js synced with upstream
Node which added a cancel() handler to close the file when the stream
is cancelled via break/throw in for-await.
Comment thread src/js/node/fs.promises.ts
Comment thread src/js/node/fs.promises.ts
Comment thread src/js/node/fs.promises.ts
Avoid retaining the caller's Buffer/URL for the handle's lifetime and
avoid dispatching through user-overridable toString at finalizer time.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/js/node/fs.promises.ts (2)

218-223: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Route the diagnostic through $ERR_INVALID_STATE.

Manually creating Error and assigning .code bypasses Bun’s centralized Node error machinery, so the uncaught diagnostic can diverge in shape/format from other ERR_INVALID_STATE errors.

Proposed fix
-  const err: NodeJS.ErrnoException = new Error(
+  const err = $ERR_INVALID_STATE(
     "A FileHandle object was closed during garbage collection. This used to be allowed " +
       "with a deprecation warning but is now considered an error. Please close FileHandle " +
       `objects explicitly. File descriptor: ${held.fd}${suffix}`,
   );
-  err.code = "ERR_INVALID_STATE";

As per coding guidelines, user-facing JS errors should go through centralized ErrorCode machinery rather than inline new Error with hand-assigned .code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/node/fs.promises.ts` around lines 218 - 223, The FileHandle GC
diagnostic is being built manually with new Error and a hand-assigned .code,
which bypasses Bun’s centralized Node error machinery. Update the FileHandle
finalization path in fs.promises.ts so the uncaught diagnostic is created
through the existing ERR_INVALID_STATE error helper/$ERR_INVALID_STATE mechanism
instead of constructing the error inline, keeping the message content and
FileDescriptor context intact.

Source: Coding guidelines


224-226: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Don’t schedule internal diagnostics through mutable process.nextTick.

Userland can replace process.nextTick, suppressing or changing the finalizer’s uncaught ERR_INVALID_STATE path. Capture the scheduler at module init and invoke it via .$call.

Proposed fix
 let fileHandleRegistry: FinalizationRegistry<{ fd: number; path: string | undefined }> | undefined;
+const processNextTick = process.nextTick;
+
 function onFileHandleCollected(held: { fd: number; path: string | undefined }) {
-  process.nextTick(() => {
+  processNextTick.$call(process, () => {
     throw err;
   });

As per coding guidelines, built-in JS modules should avoid routing internal logic through user-overridable machinery.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/node/fs.promises.ts` around lines 224 - 226, Avoid routing the
finalizer’s diagnostic throw through mutable process.nextTick, since userland
can override it and alter the ERR_INVALID_STATE path. Update the fs.promises
module to capture the scheduler once at module initialization and use that
captured reference when scheduling the throw in the relevant finalizer path. The
change should be made in the code that currently calls process.nextTick inside
the internal cleanup/diagnostics logic, preserving the uncaught error behavior
via the captured scheduler’s .$call invocation.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/test/parallel/test-whatwg-readablebytestream.js`:
- Around line 88-90: This change is in a vendored Node compatibility test, so do
not edit the existing test-whatwg-readablebytestream.js in place. Revert the
local cancel() cleanup in the upstream-mirrored test, and if the behavior still
needs coverage, add a separate Bun-owned test under test/js/node/... instead of
modifying the parallel Node test mirror. If this fix corresponds to an upstream
Node update, sync the whole vendored file from upstream rather than patching
just this method.

---

Outside diff comments:
In `@src/js/node/fs.promises.ts`:
- Around line 218-223: The FileHandle GC diagnostic is being built manually with
new Error and a hand-assigned .code, which bypasses Bun’s centralized Node error
machinery. Update the FileHandle finalization path in fs.promises.ts so the
uncaught diagnostic is created through the existing ERR_INVALID_STATE error
helper/$ERR_INVALID_STATE mechanism instead of constructing the error inline,
keeping the message content and FileDescriptor context intact.
- Around line 224-226: Avoid routing the finalizer’s diagnostic throw through
mutable process.nextTick, since userland can override it and alter the
ERR_INVALID_STATE path. Update the fs.promises module to capture the scheduler
once at module initialization and use that captured reference when scheduling
the throw in the relevant finalizer path. The change should be made in the code
that currently calls process.nextTick inside the internal cleanup/diagnostics
logic, preserving the uncaught error behavior via the captured scheduler’s
.$call invocation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cb92f2c2-6915-4722-9f78-227402ed7651

📥 Commits

Reviewing files that changed from the base of the PR and between b582948 and d404254.

📒 Files selected for processing (4)
  • src/js/node/fs.promises.ts
  • test/cli/install/bun-lock.test.ts
  • test/cli/install/bun-lockb.test.ts
  • test/js/node/test/parallel/test-whatwg-readablebytestream.js

Comment thread test/js/node/test/parallel/test-whatwg-readablebytestream.js
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Addressing the two outside-diff review findings:

$ERR_INVALID_STATE vs new Error + .code: I am intentionally not routing this through $ERR_INVALID_STATE. Bun's $ERR_INVALID_STATE(msg) prepends "Invalid state: " to the message (see ErrorCode.cpp:2107), but Node's finalizer error is constructed in native code (src/node_file.cc) and does not use that prefix. Verified against Node v26.3.0:

message.startsWith("Invalid state:") → false
name → "Error"
message → "A FileHandle object was closed during garbage collection. ..."

Using $ERR_INVALID_STATE here would make the message diverge from Node. The hand-rolled construction matches Node exactly and follows the same pattern used elsewhere in Bun when matching Node's native-constructed errors (http2.ts, net.ts, tls.ts). See also the earlier thread where the same finding was raised.

process.nextTick tamper-resistance: src/js/node/fs.ts calls process.nextTick directly eight times without capturing it, and fs.promises.ts follows that convention. Hardening only this one call site would be inconsistent with the file's existing pattern; if the maintainers want nextTick captured across the fs modules that is a separate change. Leaving as-is to match local convention.

Comment thread src/js/node/fs.promises.ts Outdated
Move the String(path) conversion from the FileHandle constructor to the
open() wrapper, before the native open runs. A Buffer/URL with a
throwing toString now surfaces before an fd exists rather than leaking
one between open and registry registration.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All prior threads are addressed and I found nothing new, but this introduces a user-visible behavior change (uncaught ERR_INVALID_STATE for previously-silent leaks) and you explicitly flagged the readableWebStream() / module-level wrapper retention question for maintainer judgment — worth a human sign-off on both.

Extended reasoning...

Overview

This PR adds a FinalizationRegistry to src/js/node/fs.promises.ts so that a FileHandle collected without close() gets its fd closed and raises ERR_INVALID_STATE via process.nextTick, matching Node's DEP0137 end-of-life behavior. It wires unregister into close(), [kCloseSync], and [kTransfer], re-registers in [kDeserialize], snapshots the diagnostic path as a string before fs.open runs, and fixes six pre-existing tests that were leaking handles (including the upstream-sourced cancel() addition to the vendored test-whatwg-readablebytestream.js). A new subprocess test in fs.test.ts asserts fds are reclaimed, diagnostics fire, and properly-closed handles produce no false positives.

Security risks

None identified. The change is fd-lifecycle bookkeeping; it does not touch auth, permissions, network input, or path validation. The finalizer's closeSync runs on an fd Bun itself opened.

Level of scrutiny

Medium-high. The diff is small but it's a behavior change, not a bug fix in the narrow sense: code that previously worked (while silently leaking an fd) will now emit an uncaught exception. That's the intended Node-compat outcome, but it's the kind of thing a maintainer should consciously ship. It also lives in a hot builtin (src/js/) and relies on GC-finalizer timing, which is inherently subtle.

Other factors

Two rounds of review feedback have been addressed (path snapshotting, ordering vs. fs.open), and the $ERR_INVALID_STATE vs. hand-rolled Error question was resolved with an empirical Node comparison. The one substantive item left open is by the author's own choice: readableWebStream() and the module-level readFile/writeFile/appendFile wrappers still borrow the raw fd without retaining the handle, and the author declined to rework them here, saying they'd do it as a follow-up if a maintainer wants it. That's a scope/design call I shouldn't make on their behalf.

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for 87612ae (build #70046, final): 284 jobs passed, including every Linux, Windows, darwin-x64, and darwin-14-aarch64 test lane. The new FileHandle GC test and all six updated test files passed on every platform that ran.

The only two failures are both :darwin: 26 aarch64 - test-bun shards that never ran a single test: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'. This diff touches only src/js/ and test files, so it cannot affect artifact download.

No test-level error annotations were generated. Ready for review; the red is infra on the darwin-26-aarch64 lane.

@Jarred-Sumner
Jarred-Sumner merged commit 46ed10f into main Jul 8, 2026
75 of 77 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/c77daebe/filehandle-gc-finalizer branch July 8, 2026 06:07
dylan-conway pushed a commit that referenced this pull request Jul 24, 2026
…File (#34283)

## What

Sync `validateReadFileProc()` with upstream nodejs/node@2eeb65fa81
(`const` to `await using`) so the `/proc/sys/kernel/hostname` FileHandle
is disposed.

## Why

#33693 added a FinalizationRegistry that throws `ERR_INVALID_STATE` when
a `FileHandle` is collected without `close()` (DEP0137 end-of-life).
This test opened the hostname fd and never closed it; whenever GC
happened to run during the later `doReadAndCancel()` section, the
registry fired and the process exited 1 with:

```
error: A FileHandle object was closed during garbage collection. This used to be allowed with a deprecation warning but is now considered an error. Please close FileHandle objects explicitly. File descriptor: 9 (/proc/sys/kernel/hostname)
 code: "ERR_INVALID_STATE"
      at onFileHandleCollected (node:fs/promises:119:78)
```

It has been in the `flaky` annotation on 140 of the last 400 Buildkite
builds (Linux lanes only, heaviest on `x64-asan` where GC pressure is
highest).

#33693 already fixed three other vendored tests the same way; this one
was missed because it is Linux-only and GC-timing-dependent. Upstream
Node made the same change in nodejs/node@2eeb65fa81.

## Verification

```
$ bun bd test/js/node/test/parallel/test-fs-promises-file-handle-readFile.js
# exit 0
```

The failure is GC-timing-dependent so a deterministic fail-before is not
available locally; the evidence is the CI annotation count plus the fact
that the unclosed handle is the only one in the file whose path matches
the error's `(/proc/sys/kernel/hostname)`.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · docs-only change; test-proof not
applicable

<!-- robobun:evidence:end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants