Skip to content

bun test: close scanner directory fds once their children are opened - #40016

Merged
Jarred-Sumner merged 2 commits into
mainfrom
claude/bun-test-dir-fds
Aug 22, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
claude/bun-test-dir-fds

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator

What

Fixes #39852
Fixes #39783

bun test kept one directory fd open for every directory the test-file scanner visited. The scanner opened each directory with openat(parent_fd, name) and passed the fd to the resolver with store_fd = true, which cached it in DirEntry.fd forever.

In a large repo that is thousands of fds. On macOS, libc's posix_spawn_file_actions_adddup2 rejects any fd number ≥ OPEN_MAX (10240) with EBADF. Once the process holds that many fds, a new stdio pipe gets a number past 10240 and every piped Bun.spawn / child_process call fails. That is the EBADF: bad file descriptor, posix_spawn in the issue.

Change

The scanner owns each directory fd. Children hold an Rc<Dir> of their parent, so the parent fd closes as soon as its last child has been opened. The resolver still caches the directory listing, just not the fd. The tree is still walked with openat(parent_fd, name), so deep paths do not hit ENAMETOOLONG in the open.

Symlinked entries no longer keep a file fd open either (Entry::kind with store_fd = false), which is the linker = "isolated" case in #39783.

600 sibling directories, fds open inside the test:

before: 1813
after:  13

Test

test/cli/test/bun-test.test.ts: scans 257 directories and asserts the open-fd count stays below 64. Fails on 1.4.1 (271), passes here (13).

@coderabbitai

coderabbitai Bot commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your current included review allowance is based on your included PR review attempts over the past 7 days.

Next review available in: 10 minutes

Limit details: You’ve used the included review currently available. Your 65 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

You’re in a promotional period — use the checkbox below to run this review for free:

  • Run review for free

On-demand reviews are free for the next 30 days. After that, they cost $0.25 per reviewed file.

How can I continue?

Run this review now using the option above, or comment @coderabbitai review --use-credits.

You can also wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 45dbebdf-0127-4e67-8674-08db5eb8c0eb

📥 Commits

Reviewing files that changed from the base of the PR and between 4e9857d and b8bf054.

📒 Files selected for processing (1)
  • src/runtime/cli/test_command.rs

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: 4aa7495b-953e-44cd-9e41-43c571221353

📥 Commits

Reviewing files that changed from the base of the PR and between 9a8ed31 and 4e9857d.

📒 Files selected for processing (3)
  • src/runtime/cli/test/Scanner.rs
  • src/runtime/cli/test_command.rs
  • test/cli/test/bun-test.test.ts
💤 Files with no reviewable changes (1)
  • src/runtime/cli/test_command.rs

Included review availability: 0 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 1 review per hour.


Walkthrough

Changes

Directory scanning now uses reference-counted Dir handles for queued parent directories and opens child directories relative to those handles. Entry processing no longer passes directory file descriptors. A Linux/macOS regression test checks descriptor retention across 64 nested trees.

Scanner directory ownership

Layer / File(s) Summary
Directory handle state and entry contract
src/runtime/cli/test/Scanner.rs
ScanEntry stores an optional Rc<Dir> parent handle. Scanner tracks the active directory. Scanner::next no longer accepts an FD.
Queued directory traversal and resolver wiring
src/runtime/cli/test/Scanner.rs
Queued directories open relative to retained parents. Child handles use Rc<Dir>. Directory reads receive optional FDs without transferring Dir ownership.
Descriptor retention regression coverage
test/cli/test/bun-test.test.ts, src/runtime/cli/test_command.rs
The regression test scans 64 nested trees and checks open-descriptor counts. An obsolete comment is removed.

Suggested reviewers: robobun

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Description check ✅ Passed The description explains the fd leak, implementation, impact, linked issues, and verification results, although its headings differ from the repository template.
Title check ✅ Passed The title clearly states that the test scanner closes directory file descriptors after opening child directories.

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

@robobun

robobun commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator

Comment thread src/jsc/bindings/bun-spawn.cpp Outdated
Comment thread test/js/bun/spawn/spawn.test.ts Outdated
The test-file scanner opened every directory with openat(parent_fd, name)
and handed the fd to the resolver with store_fd = true, so each scanned
directory stayed open for the life of the process. In a large repo that is
thousands of fds, and on macOS a stdio pipe numbered >= 10240 makes libc's
posix_spawn_file_actions_adddup2 fail with EBADF.

The scanner now owns each directory fd (Rc<Dir>): a parent stays open
only until its last child has been opened. The resolver still caches the
listing, but not the fd. Entry kinds are resolved without keeping a file
fd for symlinks.

Fixes #39852
Fixes #39783
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/bun-test-dir-fds branch from f84185e to 4e9857d Compare August 22, 2026 01:45
@Jarred-Sumner Jarred-Sumner changed the title bun test: don't hold a directory fd per scanned/resolved directory; macOS spawn works past OPEN_MAX bun test: close scanner directory fds once their children are opened Aug 22, 2026

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

The PR has been scoped down since my earlier pass — the bun-spawn.cpp / spawn.test.ts changes my previous inline comments addressed are no longer part of this diff, so those two findings can be disregarded here.

On the current 3-file change I checked for double-close and fd escape: read_directory_with_iterator with a caller-supplied handle sets should_close_handle = false and, with store_fd = false, never caches handle into entries.fd — so the Rc<Dir> is the sole owner and its Drop is the only close. The Windows path now goes through open_dir_at_windows_a's relative-to-RootDirectory handling, which already supports a real dir_fd.

Extended reasoning...

The PR was reduced from ~11 files to 3 (Scanner.rs, test_command.rs, bun-test.test.ts) after my previous review; my earlier findings on bun-spawn.cpp (maxTarget missing the Close arm) and the undrained stdout in spawn.test.ts targeted code that is no longer in this PR. On the reduced diff I traced fd ownership through Rc<Dir>: Dir::drop closes the fd, read_directory_with_iterator neither closes nor caches a caller-supplied handle when store_fd = false, and the borrow of entry.name/parent.fd ends before drop(entry). This is still a native fd-lifetime refactor with a cross-platform behavior change (Windows now opens relative to the parent fd instead of by absolute path), so it warrants a human look; the only new finding this run is the store_fd: true nit posted inline.

Comment thread src/runtime/cli/test_command.rs Outdated
@Jarred-Sumner
Jarred-Sumner merged commit e8300da into main Aug 22, 2026
9 of 10 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/bun-test-dir-fds branch August 22, 2026 03:05
// in the future we should investigate if refactoring this to not
// rely on the dir fd yields a performance improvement
store_fd: true,
store_fd: ctx.debug.hot_reload != jsc::virtual_machine::HotReload::None,

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.

🔴 Under --watch/--hot this sets resolver.store_fd = true, but the scanner now caches every walked directory with entries.fd = INVALID (Scanner.rs passes store_fd = false). When the resolver later walks a test file's directory chain in dir_info_cached_maybe_log, it finds the cached DirEntry with an invalid fd, opens a fresh fd (resolver.rs:4460), tracks it in open_dirs[], then hits needs_iter = false (cached generation >= self.generation) so the fd is never stored into entries.fd — and the defer! at resolver.rs:4423 skips the close because store_fd = true and need_to_close_files() is false. One fd leaks per test-file directory (and its uncached ancestors) on the first watch run. Fix: at resolver.rs:4634-4643, when needs_iter = false && self.store_fd and the cached entries.fd is invalid, store open_dir into it (or close it).

Extended reasoning...

What the bug is

The follow-up commit gates store_fd on hot-reload: store_fd: ctx.debug.hot_reload != HotReload::None (test_command.rs:2279). Under bun test --watch/--hot, resolver.store_fd is therefore true. Meanwhile the scanner now unconditionally passes store_fd = false to read_directory_with_iterator (Scanner.rs:235), so every directory the scanner walks is cached in the resolver's DirEntry map with entries.fd = Fd::INVALID and entries.generation = 0 (lib.rs:1318 is skipped when store_fd is false).

That combination — a scanner-cached DirEntry with an invalid fd, plus resolver.store_fd = true — trips a latent hole in dir_info_cached_maybe_log (resolver.rs:4326+): a freshly opened directory fd is neither stored into the cache nor closed by the cleanup guard.

Step-by-step trace

Take a test file at <cwd>/sub0/probe.test.ts under bun test --watch, with resolver.generation = 0 (resolver.rs:931).

  1. Scan phase. The scanner walks sub0/ and calls read_directory_with_iterator(path, Some(fd), 0, /*store_fd*/ false, iter). The resolver caches DirEntry { dir: "<cwd>/sub0", fd: INVALID, generation: 0, data: {…} }.
  2. Load phase. Loading probe.test.ts calls dir_info_cached("<cwd>/sub0"). The DirInfo cache misses (only the DirEntry cache is populated), so dir_info_cached_maybe_log builds the ancestor queue. For the sub0 slot, resolver.rs:4347-4353 finds the scanner-cached DirEntry and sets slot.fd = entries.fd = INVALID, slot.safe_path = entries.dir.
  3. resolver.rs:4460 — queue_top.fd is invalid, so a fresh open_dir fd is opened via openat.
  4. resolver.rs:4573-4577 — !queue_top.fd.is_valid() && open_dir.is_valid() → open_dir is written into bufs!(open_dirs)[open_dir_count++].
  5. resolver.rs:4626-4638 — entries.get_or_put(dir_path) returns the scanner-cached index (dir_path is entries.dir, the same interned key the scanner stored under). at_index() returns the cached Entries(entries); entries.generation (0) >= self.generation (0) → needs_iter = false.
  6. resolver.rs:4645-4698 — the entire if needs_iter { … new_entry.fd = if self.store_fd { open_dir } … } block is skipped. open_dir is passed to the inner dir_info_uncached() (resolver.rs:6099+) but only used read-side (openat(fd, ".bin"), fstat); nothing there stores or closes it.
  7. resolver.rs:4421-4428 defer! — the guard closes open_dirs[0..n] only when n > 0 && (!close_dirs_store_fd || need_to_close_files()). Here close_dirs_store_fd = true and need_to_close_files() (lib.rs:1565) returns false while file_limit > 254 && file_limit > (max_fd+1)*2, which holds after Bun raises RLIMIT_NOFILE. The condition is !true || false = false → the fd is never closed.

bufs!(open_dirs) is a threadlocal scratch buffer whose live prefix is reset by open_dir_count on the next call, so open_dir is orphaned: opened, never stored anywhere, never closed.

Why existing code doesn't prevent it

The defer! at resolver.rs:4423 assumes that when store_fd = true, every fd in open_dirs[] was written into some DirEntry.fd at line 4698 and will be reused/closed later. That holds when needs_iter = true. When needs_iter = false — a DirEntry cache hit — line 4698 never runs, and the assumption is wrong. Before this PR that state was unreachable from bun test: the scanner cached with store_fd = true, so entries.fd was valid, so line 4460 took the queue_top.fd fast path and no fresh fd was opened or tracked. The PR creates the exact combination (DirEntry cached with fd = INVALID + resolver.store_fd = true) that reaches the hole.

Impact

One fd leaks per directory that (a) the scanner cached and (b) later appears in a module-resolution DirInfo walk — i.e. every test-file directory and each of its ancestors down to the first already-DirInfo-cached dir, on the first --watch run. The need_to_close_files() safety valve (~file_limit/2) caps it, so the OPEN_MAX failure the PR fixes cannot recur, but these are truly untracked fds — an unpaired acquisition per REVIEW.md's "Pair every acquisition with its release" rule — introduced in a mode this PR explicitly special-cases at line 2279 and does not test (the new test does not run --watch).

How to fix

Fix the resolver at the layer that owns the fd: at resolver.rs:4634-4643, when needs_iter = false and self.store_fd and the cached entries.fd is invalid, store open_dir into it (so the fd is cached and reused exactly as store_fd intends). Alternatively, close open_dir on that branch, or force the defer! to close by tracking per-slot whether the fd was stored. Storing it is the correct fix for store_fd = true semantics.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants