Skip to content

node:fs: Dir.close must not close an fd it did not open - #35931

Open
robobun wants to merge 1 commit into
mainfrom
farm/a1ac9c02/fs-dir-no-foreign-fd-close
Open

node:fs: Dir.close must not close an fd it did not open#35931
robobun wants to merge 1 commit into
mainfrom
farm/a1ac9c02/fs-dir-no-foreign-fd-close

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Repro

import fs from "node:fs";
const fd = fs.openSync("/etc/hostname", "r");
const d = new fs.Dir(fd, "/never/opened/by/this/Dir");
d.closeSync();
fs.fstatSync(fd);   // bun: EBADF (fd was closed by Dir); node: fd still open

Node's Dir holds an opaque DirHandle and calls .close() on it; passing an integer there throws TypeError: this[#handle].close is not a function and leaves the descriptor untouched.

Cause

Dir in the current path-bound implementation never opens a descriptor of its own: opendir/opendirSync pass the literal 1 as the handle sentinel, and reads go through fs.readdir(path). The if (handle > 2) fs.closeSync(handle) in #closeOp/closeSync is therefore unreachable for any Dir Bun creates, and only fires when userland constructs new fs.Dir(<integer>, path) directly, closing an unrelated fd by number.

Fix

Drop the fs.closeSync(handle) from both close paths. #handle is now purely the open/closed state marker its doc comment describes.

This is orthogonal to the fd-bound rewrite in #35928; that PR stores a real fd via a private dirSetHandle setter after the native open, so a userland integer passed to the constructor would still need to be kept out of the close path there too.

Verification

# USE_SYSTEM_BUN=1
(fail) new fs.Dir with a foreign integer handle > closeSync does not close the unrelated fd
  EBADF: bad file descriptor, fstat
(fail) ... > async close does not close the unrelated fd
(fail) ... > Symbol.dispose does not close the unrelated fd

# bun bd test test/js/node/fs/dir.test.ts
26 pass, 0 fail
# bun bd test/js/node/test/parallel/test-fs-opendir.js
(exit 0)

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/fs/dir.test.ts

Dir in the path-bound implementation never opens a descriptor of its own
(opendir/opendirSync pass the sentinel 1 as the handle), so the
`if (handle > 2) fs.closeSync(handle)` in close()/closeSync() is dead for
legitimate use and only ever fires on a userland `new fs.Dir(fd, path)`,
closing an unrelated fd by number. Node treats the handle as an opaque
DirHandle and never touches an integer passed there.

Drop the closeSync calls; #handle is now purely the open/closed marker.
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:08 AM PT - Jul 26th, 2026

@robobun, your commit d699196 has 1 failures in Build #82424 (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    bun-linux-x6472.47 MB71.95 MB+528.0 KB
    bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    bun-windows-x6480.26 MB79.70 MB+570.5 KB
    bun-windows-aarch6470.86 MB70.34 MB+532.5 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 35931

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

bun-35931 --bun

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bd536520-4301-4ab1-a768-c50e6c6ab155

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and d699196.

📒 Files selected for processing (2)
  • src/js/node/fs.ts
  • test/js/node/fs/dir.test.ts

Walkthrough

Changes

Dir handle lifecycle

Layer / File(s) Summary
Dir close-state implementation
src/js/node/fs.ts
Dir documentation identifies -1 as the closed sentinel, while synchronous and asynchronous close paths now mark the handle closed without calling fs.closeSync.
Foreign handle validation
test/js/node/fs/dir.test.ts
Tests verify that synchronous close, asynchronous close, and disposal leave a foreign integer file descriptor valid.

Suggested reviewers: cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main fix: preventing Dir.close from closing an unrelated fd.
Description check ✅ Passed It covers the PR purpose and verification, even though it uses custom headings instead of the template.
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.

@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.

LGTM — correct removal of dead-for-internal / wrong-for-userland closeSync(handle) branch.

What was reviewed:

  • Verified all three internal Dir construction sites (opendir, opendirSync, promises.opendir) pass the literal 1 sentinel, so the removed handle > 2 branch was reachable only via userland new fs.Dir(int, ...).
  • Checked that #handle is otherwise used purely as an open/closed marker — no other consumer reads it as an fd.
  • Tests cover all three close paths (sync, async, Symbol.dispose), use tempDir/using for hermetic cleanup, and the probe's dispose swallows EBADF so the failing case surfaces the real assertion.
Extended reasoning...

Overview

Two-file change: src/js/node/fs.ts drops the if (handle > 2) fs.closeSync(handle) line from both Dir.#closeOp() and Dir.closeSync(), and updates the #handle doc comment to state that this path-bound implementation never owns a real fd. test/js/node/fs/dir.test.ts adds three regression tests asserting that a Dir constructed directly with a foreign integer handle does not close that fd on closeSync(), close(), or Symbol.dispose.

Correctness

I confirmed the PR's central claim by grepping src/js for new Dir(: the only constructions are in opendir (fs.ts:613) and opendirSync (fs.ts:1028), both passing the literal 1; fs.promises.opendir delegates to the callback form. So no Bun-created Dir ever had #handle > 2, and the removed branch was dead for the intended path — it fired only when userland called new fs.Dir(someFd, path) directly, in which case closing that fd diverges from Node (Node's DirHandle.close throws TypeError on an integer and touches no descriptor). The remaining uses of #handle are all < 0 / >= 0 state checks plus the = -1 write, so nothing else depended on it holding a real fd.

Security risks

None. The change removes an fd close on a value the class never opened, which is strictly safer (no more accidental close of an unrelated descriptor). No new syscalls, no new user-controlled input handling.

Level of scrutiny

Low-to-moderate. Small, self-contained JS-side Node compat fix with a clear mechanism, verified against all internal call sites, and covered by tests that the description shows fail on system Bun and pass on the debug build. The PR notes the interaction with #35928's fd-bound rewrite, which is a separate concern.

Other factors

Tests follow harness conventions (tempDir, using, per-case isolation), open a real probe fd rather than assuming a fixed number, and wrap the probe's cleanup closeSync in try/catch so the USE_SYSTEM_BUN failing case reports the fstat assertion instead of a cleanup EBADF. The test-fs-opendir.js parallel test still passes per the description. No prior review comments to address.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI complete: all 195 test/build jobs passed. The only red is :package: binary-size, which compares against stale canary baseline #79916; neighbor PR builds #82421 and #82422 show byte-identical deltas, and this diff only removes code. Ready for review.

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