Repository navigation
Conversation
read_directory_with_iterator opens a directory, then iterates it. The close guard was gated on '!store_fd || need_to_close_files()', which is the 'we never intend to cache this fd' condition. When store_fd is set (bun test / --hot / --watch / install) and the fd budget is high (always on Windows, almost always on POSIX after the rlimit bump), the guard never closed. On the success path that is correct: the fd is published into the cached DirEntry. On the error paths it leaked: DirnameStore.append_slice? and the readdir Err arm both return before the fd is published. The guard now also tracks whether the handle has been published. Until it has, a freshly-opened handle is closed on every exit path; once readdir returns Ok (which writes e.fd = handle when store_fd), the guard is disarmed and the existing success-path behaviour is unchanged.
WalkthroughThe resolver now tracks directory handle publication and closes handles on failed reads. A Linux/glibc regression test forces ChangesResolver file descriptor cleanup
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
LGTM — targeted fd-leak fix on the resolver's readdir error path.
What was reviewed:
- Traced all exit paths of
read_directory_with_iteratoragainst the new guard condition: error paths now close, and thestore_fdsuccess path is byte-for-byte unchanged (should_close_handle=false∧handle_published=true⇒ no close). - Checked drop order:
handle_published: Cell<bool>is declared before_close_guard, so it's still live when the guard closure runs; the closure borrows it shared, which composes with the later.set(true). - Confirmed no double-close on the readdir
Errpath —DirEntryhas noDrop, so the partial value dropping does not closedir.fd. - Test: skip gating (
!isLinux || isMusl || !cc) matches the LD_PRELOAD/procfs requirements;beforeAllearly-return +dir?.inafterAllis safe; pipes drained concurrently;process.exit(0)terminates--hot.
Extended reasoning...
Overview
The PR fixes an fd leak in src/resolver/lib.rs::read_directory_with_iterator. The function opens a directory, arms a scopeguard to close it, then interns the dirname and calls readdir. The old guard condition was !had_handle && (!store_fd || need_to_close_files()) — i.e. "close only if we never intended to cache this fd". When store_fd=true and the fd budget is comfortable, that condition is false, so the guard is inert. On the success path that's correct (the fd is published into the cached DirEntry), but on the two early-return paths (DirnameStore::append_slice? and readdir → Err) the fd is never published and leaks.
The fix adds a Cell<bool> handle_published that starts false and flips to true only after readdir returns Ok. The guard now closes when !had_handle && (should_close_handle || !handle_published). I walked every combination:
had_handle— never closes (unchanged; caller owns it).- fresh handle, error before
readdirOk —!handle_published⇒ closes (the fix). - fresh handle,
store_fd, budget OK,readdirOk —false || !true⇒ keeps (unchanged; fd lives ine.fdand is moved into the BSSMap slot / leaked Box before the next?). - fresh handle,
!store_fdor over-budget,readdirOk —should_close_handle⇒ closes (unchanged).
The readdir body (lines 1080–1110) sets dir.fd = handle when store_fd, but DirEntry has no Drop, so on Err the partial dir dropping does not close the handle — no double-close with the guard. Drop order is correct: handle_published outlives _close_guard, and Cell::get/set both take &self so the shared capture composes with the later mutation.
Security risks
None. This is a resource-leak fix on an error path; no new inputs are parsed and no trust boundary is crossed.
Level of scrutiny
Moderate. The resolver is hot, but this change is confined to the guard closure and one match arm — the success-path condition reduces to exactly the old expression once handle_published is true. The failure mode being fixed (getdents64 failing after a successful open) is rare, so regression risk on the common path is the main concern, and that path is provably unchanged.
Other factors
The test is well-constructed for a hard-to-reach syscall failure: an LD_PRELOAD shim fails SYS_getdents64 for marker directories, the inner process runs under --hot (so store_fd=true), and leaked fds are counted via /proc/self/fd. It's correctly gated to Linux+glibc with a compiler present, drains subprocess pipes concurrently, uses tempDir/bunEnv/bunExe, and asserts a combined {stdout, stderr, exitCode} object. The PR description shows before=32/after=0, and the neighboring resolve/hot suites are reported unchanged.
There was a problem hiding this comment.
LGTM — targeted fd-leak fix; success path is provably unchanged, error paths now close the freshly-opened handle.
Checked: guard condition when handle_published=true reduces to exactly the old should_close_handle, so no behaviour change on success and no new double-close. On the readdir Err / DirnameStore::append_slice Err paths the local DirEntry (no Drop) is discarded and the guard now closes — verified readdir sets dir.fd = handle only under store_fd, so the !store_fd close was already single. Drop order (_close_guard before handle_published: Cell) is correct. Test asserts FAILED=16 so it self-invalidates if getdents64 stops routing through libc syscall().
Extended reasoning...
Overview
Two files: a ~10-line change to the scopeguard in read_directory_with_iterator (src/resolver/lib.rs) and a new Linux-glibc-only regression test that LD_PRELOADs a shim forcing getdents64 to fail with EIO for marker directories, then counts leaked fds via /proc/self/fd.
Correctness of the guard change
The old guard computed !had_handle && (!store_fd || need_to_close_files()) once and closed on that. The new guard computes the same expression as close_even_if_published, and ORs in !handle_published. handle_published flips to true only after self.readdir(...) returns Ok. So on success the condition collapses to the old one — byte-for-byte identical behaviour, no new double-close and no regression in the store_fd && need_to_close_files() corner. On the two early-return paths (DirnameStore::append_slice(...)? and readdir Err), handle_published is still false, so any freshly-opened handle (!had_handle) is closed. I confirmed readdir sets dir.fd = handle before iteration only when store_fd, and DirEntry has no Drop, so the Err-path close is single. handle_published is a Cell declared before _close_guard, so it is still live when the guard drops; the non-move closure borrows it, and handle/had_handle outlive the guard as well.
Security risks
None. This is resource-lifetime bookkeeping on a directory fd the resolver already opened; no new inputs are trusted, no new syscalls are added.
Level of scrutiny
Moderate — resolver fd handling is load-bearing for --hot/--watch/bun test, but the change is a strict tightening of an existing guard whose success-path semantics are mechanically preserved. The one remaining ? after handle_published.set(true) (self.entries.put) already leaked the fd into a leaked Box<DirEntry> in the pre-PR code under store_fd, so no regression there.
Other factors
The comment-cop bot's three notes were addressed in follow-up commits (guard comments trimmed; variable names now carry the intent) and are marked resolved. The test is properly gated (skipIf(!isLinux || isMusl || !cc)), spreads bunEnv, drains stdout/stderr/exited concurrently, uses tempDir, and asserts FAILED=DIR_COUNT so it fails loudly rather than passing vacuously if the interposition stops working. The PR notes resolve.test.ts, import-meta.test.js, and hot.test.ts are unchanged.
There was a problem hiding this comment.
Beyond the inline finding, I also checked: drop order of handle_published vs _close_guard (Cell outlives the guard, so the borrowed .get() is valid); the success-path close condition is byte-identical to the old should_close_handle, so no new double-close; and the ? at self.entries.put(...) fires only after *entries_ptr = entries has moved the fd into persistent storage, so handle_published=true is accurate there.
Extended reasoning...
The scopeguard rewrite is sound for the !had_handle path it targets, and the LD_PRELOAD test proves the shim fired via FAILED=DIR_COUNT. The inline comment covers the one same-class gap (caller-supplied handle from Scanner). Deferring rather than approving because this is fd-lifecycle logic in a shared resolver path and the sibling-arm leak is exactly the "fix the whole class" case REVIEW.md flags.
The only Some(handle) caller (the bun test scanner) disarms its Dir guard via into_raw() before the call, so read_directory_with_iterator owns the fd on that path as well. Closing whenever the fd was never published covers both arms; the close-even-after-publish (!store_fd / fd-budget) behaviour stays gated on !had_handle so a caller-supplied fd that was successfully cached is never double-closed. Add a second test driving bun test's scanner through the same shim.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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/bun/resolve/resolver-readdir-error-fd-leak.test.ts`:
- Around line 144-176: Add a test file inside each marker directory that would
produce a detectable success signal if executed, then assert in the parent test
that this signal is absent. Update the fixture and assertions around the
generated `files` map and `LEAKED` result while preserving the existing leak and
process checks, so the test proves marker directories could not be enumerated
and the shim exercised the intended cleanup path.
🪄 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: c8585c7d-dd7d-425a-913d-2e0a2bf68478
📒 Files selected for processing (2)
src/resolver/lib.rstest/js/bun/resolve/resolver-readdir-error-fd-leak.test.ts
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/resolver/lib.rs:1233-1239—RealFS::kind()(~250 lines below, lib.rs:1481-1491) has the byte-identical(!store_fd || need_to_close_files) && !existing_fd.is_valid()gate and the same leak: withstore_fdand a fresh open, whenfstat?orget_fd_path?early-return the guard's else-if writesfileinto the stack-localcache(EntryCacheis#[derive(Clone, Copy)], no Drop) and the fd is orphaned. This is pre-existing, but REVIEW.md is explicit that same-class sibling sites are one concern for the same PR — the samehandle_published-style flip applies directly there.Extended reasoning...
What the bug is
The scopeguard in
RealFS::kind()'s symlink branch (src/resolver/lib.rs:1481-1488) uses the same success-path-only gate this PR just removed fromread_directory_with_iterator:let _guard = scopeguard::guard(file, move |file| { if (!store_fd || need_to_close_files) && !existing_fd.is_valid() { let _ = bun_sys::close(file); } else if bun_core::feature_flags::STORE_FILE_DESCRIPTORS { unsafe { (*cache_ptr).fd = file }; } }); let file_stat = bun_sys::fstat(*_guard)?; symlink = bun_sys::get_fd_path(*_guard, &mut outpath)?;
When
store_fd = trueandneed_to_close_files() = false(always on Windows per lib.rs:1534-1541; usually on POSIX after the rlimit raise per 1546-1547), the first branch is false regardless of whether the fd was ever published. The else-if writes the fd into*cache_ptr, which points at a stack-localEntryCache— andEntryCacheis#[derive(Clone, Copy)](src/resolver/fs.rs:116-117), so it has noDrop. On the?early-return the stack local is discarded and the fd leaks.Step-by-step proof
Under
bun --hot/bun test(sostore_fd = true), resolving through a symlink where the open succeeds but the follow-up syscall fails:lstatat 1449 reportsSymLink;existing_fdis invalid (fresh entry).- Line 1460-1464:
open_file_absolute_z(...)?.into_raw()opens the target and disarms Drop —fileis now a rawFdowned by nobody. need_to_close_files()is false (fd budget not exceeded).- Line 1490:
bun_sys::fstat(*_guard)?returnsErr(EIO)— e.g. a FUSE mount vanished between open and fstat, or the underlying device returned EIO. (Or line 1491:get_fd_path, i.e.readlink("/proc/self/fd/N")on Linux, fails with ENAMETOOLONG or EIO.) - The
?unwinds._guarddrops first (reverse declaration order):(!store_fd || need_to_close_files) && !existing_fd.is_valid()=(false || false) && true= false. Else-if fires:(*cache_ptr).fd = filewrites the fd into the stack-localcache. cachedrops — butEntryCacheisCopy, so nothing runs.kind()returnsErr. The fd is orphaned.
This is the exact mechanism the PR description names for
read_directory_with_iterator: "the guard is gated on 'we never intended to cache this fd' … On the success path that is what we want … On the early-return paths it leaks."Why existing code doesn't prevent it
The guard was written assuming the else-if's "store" branch always publishes into a value that survives the return. On the
Okpath that's true —cacheis returned by value and the caller stashes it in the entry map. On theErrpathcachenever leaves the stack frame, andEntryCachedeliberately has no Drop (it'sCopy; the fd is meant to be owned by the long-lived cache, not the struct). Nothing else inkind()touchesfileafter the guard is armed.Impact
Same rarity class as the bug this PR fixes: open succeeds, then the very next syscall on that fd fails (EIO on flaky media, a FUSE mount disappearing between the two calls, or
readlinkof/proc/self/fd/Nhitting ENAMETOOLONG). One fd leaked per failing symlink resolve. Under--hot/--watchthe resolver's bust-and-retry can re-hit it repeatedly, same as the readdir case. Low absolute impact, but it's the identical bug class in the same file, ~250 lines below, using the byte-identical(!store_fd || need_to_close_files)gate expression.Why this belongs in this PR
REVIEW.md, under Correctness: the bug class, not the bug:
Fix the whole class in the same PR (same-class sites are ONE concern, not scope creep). Grep for every sibling site sharing the pattern … If a site is intentionally excluded, say so in the PR.
Grepping
(!store_fd || need_to_close_files)finds exactly this site. The PR already extended the fix to thehad_handlesibling arm after the earlier review round;kind()is the remaining sibling of the same gate.Fix
The same shape as the PR's own fix: track a
publishedflag that flips only after the fd has been written into a value theOkpath returns, and close on drop whenever!published(regardless ofstore_fd). Concretely, move(*cache_ptr).fd = fileout of the guard to just before theOk(cache)tail (afterget_fd_path?succeeds), and have the guard close whenever the fd was freshly opened and not yet stored — e.g.:let published = core::cell::Cell::new(false); let _guard = scopeguard::guard((), |()| { if !existing_fd.is_valid() && (!published.get() || !store_fd || need_to_close_files) { let _ = bun_sys::close(file); } }); let file_stat = bun_sys::fstat(file)?; symlink = bun_sys::get_fd_path(file, &mut outpath)?; file_kind = kind_from_mode(file_stat.st_mode as bun_sys::Mode); if store_fd && bun_core::feature_flags::STORE_FILE_DESCRIPTORS { cache.fd = file; published.set(true); }
(Note
FilenameStore::instance().append_slice(symlink)?at 1504 is another?after the guard has dropped — moving the store to just before the return, or flippingpublishedright afterget_fd_pathsucceeds, keeps that path safe too.)
The guard was armed after the cache lookup, so a caller-supplied handle leaked on the three earlier exits (get_or_put? OOM, the cached-entry return, and the cached-NotFound return). The guard now holds the Option<Fd> from function entry and is armed before any exit path; open_dir writes into the same cell on a cache miss. The doc comment's ownership contract is now accurate for every return. Also surface the actual LEAKED count / pass count in the scanner test's assertion object so a failure diff is informative.
|
On the |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/resolver/lib.rs (1)
1268-1270: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winDo not mark an unstored handle as published.
When
store_fdisfalse,readdirdoes not assignhandletoDirEntry.fd. Line 1269 still marks a caller-supplied handle as published. The guard then skipsclose(), so each successful call leaks that descriptor.Set publication only when
entries.fdretains the handle. Add a regression case that passesSome(Fd)withstore_fd: false.Proposed fix
Ok(e) => { - handle_published.set(true); + handle_published.set(store_fd && e.fd.is_valid()); e }As per coding guidelines, “Pair every resource acquisition with release at the acquisition site, including all early-return, error, success, and lifecycle paths.”
🤖 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/resolver/lib.rs` around lines 1268 - 1270, Update the success branch around the visible handle-publication logic so `handle_published` is set only when `DirEntry.fd` actually retains the supplied handle; when `store_fd` is false, leave it unset so the descriptor is closed. Add a regression test covering `Some(Fd)` with `store_fd: false` and verify the descriptor is released.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.
Outside diff comments:
In `@src/resolver/lib.rs`:
- Around line 1268-1270: Update the success branch around the visible
handle-publication logic so `handle_published` is set only when `DirEntry.fd`
actually retains the supplied handle; when `store_fd` is false, leave it unset
so the descriptor is closed. Add a regression test covering `Some(Fd)` with
`store_fd: false` and verify the descriptor is released.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 0b168859-1b1a-49e4-a6d6-ca5fa4b80f32
📒 Files selected for processing (2)
src/resolver/lib.rstest/js/bun/resolve/resolver-readdir-error-fd-leak.test.ts
readdir writes e.fd = handle only when store_fd; checking e.fd == handle makes the 'published' flag accurate for a hypothetical caller that passes Some(fd) with store_fd=false (none exist today, but the doc comment now claims ownership on every path).
…hed the fd
readdir sets dir.fd = handle before iterating and add_entry_with_store
calls iterator.next(entry, self.fd) for each entry produced, so when a
later getdents64 batch fails the iterator (Scanner) has already queued
ScanEntry { relative_dir: handle }. Closing the fd here would make the
scan loop open_dir_at against a freed (or kernel-reused) fd number.
When I::IS_VOID (every read_directory caller: load_as_file, install,
lockfile) the iterator is never called and closing on Err is safe; that
is the leak this PR fixes. When not (only the bun test scanner), the
Err arm now treats the fd as published so the pre-PR 'hold and leak'
behaviour is kept on that one path.
Repurpose the scanner test to guard the hold: it now asserts HELD=16,
so a future change that closes in this path (reintroducing the
use-after-close) flips it.
|
CI at 4502d27: all 9 failures are marked flaky by the runner (fastutf8stream-reopen, run-crash-handler, napi GC-condition, and six parallel-batch-only failures on Windows/alpine). None touch the resolver, the new test, or any The diff is ready. All review threads are resolved; the last finding was marked pre-existing by the reviewer. |
Repro
Under any mode that caches directory fds (
bun --hot,bun --watch,bun install,bun test), resolve a path whose parent directory can be opened but whosegetdents64fails:getdents64failing after a successfulopen(O_DIRECTORY)is rare but reachable: a filesystem that returns EIO mid-iteration, a FUSE mount that disappears between the two syscalls, or a directory the process may search but not read. The runtime's NotFound retry inVirtualMachinealso busts the cachedErrand re-opens once perrequire.resolve, so an unresolvable directory leaks one fd on every call rather than once total.Cause
read_directory_with_iteratoropens the directory (or accepts one from the caller), then sets up a scopeguard to close it. The guard was gated on!had_handle && (!store_fd || need_to_close_files()): the "we never intended to cache this fd" condition. Whenstore_fdis set andneed_to_close_files()is false (always on Windows; on POSIX once the raised rlimit puts the process well under its fd budget), that condition is false and the guard never closes.On the success path that is what we want:
entries.fd = handlepublishes the fd into the cachedDirEntry. On the early-return paths it leaks:DirnameStore::append_slice(...)?andreaddir(...)returningErrboth return before the fd is published, andDirEntryhas noDrop.Fix
The guard now holds
Cell<Option<Fd>>initialized frommaybe_handleand is armed before any exit path. It closes whenever the handle has not been published, with the pre-existing close-after-publish behaviour for a freshly-opened fd we never intended to cache kept under!had_handle.open_dirwrites into the same cell on a cache miss.handle_publishedis set whenreaddirreturnsOkwithe.fd == handle, so the success path is unchanged.One exception to "close when not published": when the iterator is non-void (only the
bun testscanner),readdirdispatches the fd to the iterator for every entry it yields, so a later iteration failure leaves queuedScanEntry { relative_dir: fd }values that the scan loop will still use. Closing there would be a use-after-close against those queued entries, so the Err arm with a non-void iterator keeps the pre-PR hold-and-leak behaviour. Everyread_directorycaller (load_as_file, install, lockfile) uses the void iterator and gets the close.RealFS::kind()shares the same gate expression in its symlink branch; it is excluded here because its failure points (fstat,get_fd_path) route through rustix's linux_raw backend and cannot be driven by the LD_PRELOAD shim, so there is no fail-before proof available. It has been handed off separately.Verification
test/js/bun/resolve/resolver-readdir-error-fd-leak.test.tsbuilds an LD_PRELOAD shim that makessyscall(SYS_getdents64, fd, ...)fail with EIO when the fd's/proc/self/fdtarget contains a marker string, then:bun --hot inner.ts(soresolver.store_fd = true,maybe_handle = None, void iterator), resolves one path per marker directory, and assertsFAILED=16 LEAKED=0(FAILEDproves the shim fired);bun test .over a tree with marker subdirectories so the scanner opens each one and hands the fd in with its non-void iterator, and assertsHELD=16(the fds stay valid for queued work; this guards the use-after-close regression).Before:
LEAKED=32. After:LEAKED=0. The scanner test passes on both, by design.test/js/bun/resolve/resolve.test.ts,test/cli/test/bun-test.test.ts,test/js/bun/resolve/import-meta.test.js, andtest/cli/hot/hot.test.tsare unchanged.no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/resolve/resolver-readdir-error-fd-leak.test.ts