node:fs: release descriptor-owning async jobs when their worker is gone - #37278
node:fs: release descriptor-owning async jobs when their worker is gone#37278robobun wants to merge 1 commit into
Conversation
A worker terminated (or exiting) with fs thread-pool jobs still in flight dropped those jobs without running them, leaking the descriptors they owned: - an fs.close job the pool reached only after the worker's VM handle closed was dropped without performing the close, so the fd the caller handed over leaked on every terminate (regression: the previous teardown waited for in-flight fs work) - an fs.open job that did run but whose completion was refused dropped the freshly opened fd without closing it (pre-existing) Job::run_on_pool's VM-gone branch now tells the job its run will never happen (JobContext::run_refused, default no-op); AsyncFSTask routes that to a per-op NodeFSDispatch::release_unrun hook, and the Close op closes the descriptor it owns. ret::Open becomes an owning newtype whose Drop closes the fd unless fs_to_js delivered it to JS, covering the refused and unrun drop paths on POSIX and the UVFSRequest drop on Windows. In-flight close/terminate repro: 48 fds leaked over 20 rounds before, 0 after. Two deterministic tests added to worker-refused-completion.test.ts using the refusal gate plus UV_THREADPOOL_SIZE=2 to pin the pool; they fail on the unfixed build and pass with the fix.
|
Warning Review limit reached
Next review available in: 12 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
There was a problem hiding this comment.
I reviewed this PR and the bug hunting system found no issues. Because it adds a new lifecycle hook to the core JobContext carrier and changes ret::Open to an owning type with Drop, a human look at the cross-thread ownership reasoning would still be worthwhile.
What was reviewed:
run_refusedordering inrun_on_pool: called beforedone.finish(); the subsequentrelease_refuseddrop ofoffcannot double-close (FDisCopy, noDrop).ret::Opennewtype flows: sync path (node_fs_binding.rs:54-57), asyncthen, and WindowsUVFSRequest::run_from_js_threadall go throughfs_to_js, whichmem::replaces toFD::INVALIDso the guardedDropis a no-op on the delivered path.close_unrunuses the sameclose_allowing_standard_iohelper asNodeFS::close; safe on a pool thread with no VM borrow.- On non-Windows
UVFSRequestaliasesAsyncFSTask, so theunrun = close_unrunhook is reachable forClose; tests correctlyskipIf(isWindows).
Extended reasoning...
Overview
The PR fixes fd leaks when a worker terminates with async fs.close/fs.open jobs in flight. It touches three files: src/jsc/job.rs (adds JobContext::run_refused default-no-op hook, called from the VM-gone branch of run_on_pool), src/runtime/node/node_fs.rs (adds NodeFSDispatch::release_unrun per-op hook wired through the node_fs_ops! macro; changes ret::Open from a type alias for FD to an owning newtype with Drop; adds close_unrun), and two new deterministic fd-leak tests in worker-refused-completion.test.ts.
Security risks
None identified. The change only affects when close(2) is issued for descriptors the runtime already owns; there is no new user-input parsing, no auth/crypto, no privilege boundary.
Level of scrutiny
High. src/jsc/job.rs is the cross-thread job carrier that mediates VM lifetime, JsSide release, and pool-thread ownership — the module doc explicitly frames it around memory-safety invariants. Adding a hook that runs on a pool thread with no VM borrow, and changing a return type to have a Drop that issues a syscall, both require tracing every consumer to rule out double-close or use-after-close. I traced the sync dispatch path, the async then path, the Windows UVFSRequest completion path, and the three release paths (release_refused, release_unrun_on, normal then drop) and found the ownership handoff correct in each — fs_to_js neutralizes the fd via mem::replace(.., FD::INVALID) before the guarded Drop runs, and FD itself is Copy with no Drop so close_unrun followed by the args drop cannot double-close.
Other factors
- The macro extension (
unrun = ...) is opt-in per op and onlyCloseuses it, so the blast radius is contained. close_unrunreuses the exact helper (close_allowing_standard_io) thatNodeFS::closealready uses, matching the repo's "use the in-tree helper" rule.- Tests use the existing
BUN_DEBUG_TEST_WORKER_REFUSAL_GATEmechanism and poll the fd table with a deadline (no bare sleeps), gated to debug/ASAN builds like the surrounding tests. - The PR author ran the full fs test suites and cross-target
cargo check.
That said, this is not a mechanical change — it introduces a new lifecycle contract on a core trait and an owning Drop on a value that flows through generated dispatch code on three platforms. Per the approval guidelines (complex change touching critical code paths), I'm deferring rather than approving.
|
Updated 11:18 AM PT - Aug 9th, 2026
❌ @robobun, your commit f7b9bb4 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 37278That installs a local version of the PR into your bun-37278 --bun |
|
CI: 195/196 jobs passed. The one red job is darwin 14 x64, failing on test/cli/test/parallel.test.ts (the >64MB result line test timing out at 60s), which also fails on main and is unrelated to this change. The remaining annotations are known-flaky tests that passed on retry. The diff itself is green on every lane, including the debug/ASAN lanes that run the new worker-refused-completion fd tests. |
|
Re-checked on current main (97a4363) after the teardown rework in #38299. Both legs still leak, so this stays open; the branch needs a rebase onto the ticket model. Observed on a debug build of main, with only this PR's tests applied (
For the rebase: the |
Symptom
Terminating a worker (or a worker exiting on its own) with
fs.close()calls in flight leaks the file descriptors. A long-lived parent that spawns and terminates workers doing fs work accumulates fds until EMFILE.48 fds leaked over 20 rounds on current canary; 0 on builds before the worker teardown rework (the old teardown waited for in-flight fs work). The sibling leg leaks on every build: an in-flight
fs.open()whose worker goes away leaks the descriptor it just opened.Cause
Async fs operations run as
Job<AsyncFSTask>on the work pool. When the worker's VM handle is already closed by the time the pool reaches the job,Job::run_on_poolskipsC::runand the refused job is dropped. Two resources go with it:args::Closeowns the fd the caller handed over; dropping the job without running it means the close(2) never happens.ret::Open = FDholds the fd a completed open produced; a completion refused at the handle (or released unrun at teardown) dropped it without a close.Fix
JobContext::run_refused(default no-op), called fromrun_on_pool's VM-gone branch: the one place a job learns its run will never happen.AsyncFSTaskroutes it to a per-opNodeFSDispatch::release_unrunhook; theCloseop closes the descriptor it owns (there is nowhere left to report an error, matching what the completion-less teardown already implies).ret::Openbecomes an owning newtype:FsReturn::fs_to_jstakes the fd out when delivering it to JS, andDropcloses a still-valid fd. That covers the refused drop (pool thread), the unrun release (JS thread at teardown), and the WindowsUVFSRequestdrop with one mechanism.Verification
fs.openvariant: 0 after (leaked on every build before).worker-refused-completion.test.ts, usingBUN_DEBUG_TEST_WORKER_REFUSAL_GATEplusUV_THREADPOOL_SIZE=2: two parked stat completions pin the pool so the close jobs are provably still queued when the handle closes, and the open results are provably refused. Both fail on the unfixed build (2-8 fds leaked) and pass with the fix.worker-refused-completion.test.ts17/17,worker-terminate-funnels.test.ts10/10,fs.test.ts458 pass,fs/promises.test.js30 pass,worker_threads.test.ts121 pass.cargo check -p bun_runtimefor x86_64-pc-windows-msvc, aarch64-pc-windows-msvc, aarch64-apple-darwin.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/web/workers/worker-refused-completion.test.ts