Conversation
|
Status: reproduced on main with the fixture in this PR ( |
WalkthroughSummaryAsynchronous Node filesystem operations now retain abort signals on the JS thread and share atomic cancellation flags with worker threads. Tests cover signal cleanup and worker teardown. Related ChangesFilesystem abort lifecycle
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
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/node/fs/abort-signal-read-write-file-worker-teardown-fixture.ts`:
- Around line 51-52: Update the queued readiness promise around
worker.once("message") so it rejects when the worker emits "error" or exits
early with a nonzero status before posting the ready message. Preserve
resolution on the expected "message" event and ensure all failure events are
wired to rejection so the fixture cannot hang.
🪄 Autofix
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: 5ac79998-5497-4609-9103-549ee4365904
📒 Files selected for processing (7)
src/runtime/api/js_bundle_completion_task.rssrc/runtime/node/node_fs.rssrc/runtime/socket/SSLConfig.rssrc/runtime/webcore/Blob.rssrc/runtime/webcore/fetch.rstest/js/node/fs/abort-signal-read-write-file-worker-teardown-fixture.tstest/js/node/fs/promises.test.js
|
Updated 6:18 AM PT - Aug 11th, 2026
✅ @robobun, your commit 16554722e4bd09dcfe31adf5aeee4f609fbc4d3f passed in 🧪 To try this PR locally: bunx bun-pr 37420That installs a local version of the PR into your bun-37420 --bun |
|
Pushed two follow-ups to the review comments: the fixture now rejects its readiness promise if the worker errors or exits before queueing (a worker-side failure exits the fixture with that error instead of sitting there until the test times out), and the new doc comments in |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Not a duplicate of #37170. That PR is about teardown hanging on fs ops that never complete, and it is written against the pre-#37075 task model ( This PR changes who owns the signal under the current |
Jarred-Sumner
left a comment
There was a problem hiding this comment.
This adds a heap allocation and adds more state to track. Not necessary.
Feels like the better design here is:
- Continue to ref/unref on main thread, only before entering/exiting JS
- Make it so an AbortSignal can read the aborted boolean atomically across threads
|
Agreed, reworking it that way: no allocation and no listener. The pool will poll |
…d, not on the pool
readFile, writeFile and appendFile with { signal } kept the AbortSignal ref
(and its pending-activity count) in the arguments, which travel with the
off-thread half of the pool job. A job still queued on the pool when its
worker is torn down is freed later by a pool thread, so that thread released
what was by then the last ref and ~AbortSignal ran against a destroyed VM:
EventListenerMap's thread check fires for a signal with abort listeners,
AbortSignal.timeout's timer deinit looks the VM up on the pool thread, and an
aborted signal's reason releases a JSC::Weak into the freed heap.
The arguments now carry only a pointer to the signal, and all the operation
does with it is poll aborted(), whose flags byte is atomic on the C++ side
(GC marker threads were already reading it through JSAbortSignalOwner). What
keeps the signal alive while the operation is pending (GC protection on the
wrapper, plus the pending-activity count as before) is args::SignalHold:
a synchronous call drops it with the arguments, and AsyncFSTask::create moves
it onto the job's JS side, which the completion or the VM's teardown releases
on the JS thread like the job's other JS state. The retention is a protection
rather than a ref of our own because teardown releases JS sides before it
waits for bodies still running on the pool: unprotecting only makes the
wrapper collectible, and nothing collects it before the VM is destroyed,
after those bodies are done, whereas dropping a last ref would free the
signal under a body still polling it.
ReadFile/WriteFile no longer need Drop impls; the three default-then-assign
construction sites that existed because of them are struct literals now.
1a12b87 to
18401a3
Compare
|
Reworked as suggested and force-pushed as one commit (18401a3), description updated.
One deliberate detail: the retention is a protection rather than a C++ ref held on the JS side, because teardown releases the JS sides before it waits for bodies still running on the pool. Unprotecting leaves the wrapper alive until the VM is destroyed (after those bodies are done), while dropping a last ref there would free the signal under a body still polling it. Details in the description. Both tests unchanged in substance; the teardown one got an explicit timeout since a worker start plus teardown alone is ~3s on a debug build and it timed out once on a loaded machine. |
There was a problem hiding this comment.
I reviewed this and didn't find any bugs. Given it reworks cross-thread ownership of AbortSignal (JS-side release via AsyncFSJs, a JsPtr polled from the pool, and m_flags made atomic), a human look at the lifetime/ordering argument would still be worthwhile.
What was reviewed:
- The
Signal/SignalHoldsplit andtake_signalplumbing intoAsyncFSJs;pending_activity_ref/unrefpairing on all paths (sync drop, completion,release_all_js). - The
JsPtr<AbortSignal>SAFETY claim vs. teardown ordering — checked thatProtectedrelease only unprotects the wrapper (finalization is a later GC/VM-destruction step, after bodies under a borrow finish); ruled out as a bug. AbortSignal.h:m_flags→std::atomic<uint8_t>withfetch_or/fetch_and; the consolidatedsetFlagpreserves the previous per-flag setters' behavior, and existing GC-thread reads (JSAbortSignalOwner) now go through the same atomic load.- The three
ReadFile { .. }FRU call sites and the fixture (error/exit now reject the readiness promise — earlier comment addressed).
Extended reasoning...
Overview
The PR fixes a crash where ~AbortSignal() runs on a thread-pool thread against a dead VM when a worker with queued readFile/writeFile/appendFile({ signal }) is torn down. The fix moves the signal's retention (GC protection + pending-activity count) from the off-thread args::ReadFile/WriteFile into the job's JS side (AsyncFSJs.signal), so it is released on the JS thread by completion or JobList::release_all_js. The pool-thread half keeps only a JsPtr<AbortSignal> and polls aborted(), which is now an atomic load (m_flags becomes std::atomic<uint8_t>). ReadFile/WriteFile lose their Drop impls, so three internal call sites switch back to functional-record-update. Two new tests: a FIFO-parked worker-teardown fixture (POSIX) and a heap-stats leak guard over all seven signal-taking entry points.
Security risks
None identified. This is internal lifetime management; no user-controlled input reaches new parsing or allocation.
Level of scrutiny
High. Per REVIEW.md this sits squarely in the most-blocked category — cross-thread ownership of a refcounted C++ object with GC handles, a new JsPtr whose validity depends on the ordering between release_all_js, wrapper finalization, and pool bodies still holding a Borrow, and a change to C++ atomics/memory ordering. The approach was also reworked mid-review (from Arc<AtomicBool> + native listener to atomic m_flags + JsPtr + Protected), so the PR description is one revision behind the code. That is exactly the kind of subtle invariant a maintainer familiar with job.rs teardown should sign off on.
Other factors
The fixture-readiness comment I left earlier is addressed (worker error/exit now reject). The comment-cop threads are resolved (the flagged blocks are gone). The bug-hunting verifier examined the JsPtr SAFETY claim against teardown and refuted it as a bug. CI is still building per the last robobun status. This supersedes the signal-leak arm of #37170 (noted in the description); a maintainer may want to weigh the interaction.
|
The description was refreshed right after the force-push and matches 18401a3 now (atomic flags byte, pointer in the arguments, |
|
The GC protection on the wrapper is not needed. The wrapper is kept alive while there are pending native callbacks due to has pending activity. |
|
I tried exactly that: dropped the protection and kept only the ref + pending activity on the JS side (main's hold, relocated). It is not enough in one case, and it reproduces. Pending activity keeps the wrapper alive only while the signal has an abort listener (the nesting in Same script against the current head (with the protection): clean, 3 of 3. That is what the protection is for: it makes the pointer the body polls valid until the VM is destroyed, which is the lifetime the job model already assumes for everything else a body reads off-thread (buffers, wrappers). Main does not have this particular read because main keeps the ref in the off-thread half, which is the original bug. So the choices as I see them: keep the one protected field (current head), or make teardown wait for in-flight bodies before it releases JS sides, which fixes this class generically but changes the teardown sequence from #37075. I have left the PR on the first; glad to switch to the second if you would rather have that. The probe below is not in the PR as a test because it needs a sleep to order teardown ahead of the poll. probe (mkfifo a FIFO, then
|
…n fixture self-checking - A readFile whose controller, signal and reason are referenced by nothing else: the operation shows up as exactly one protected AbortSignal while in flight, the rejection keeps its cause across a GC, and the protection is gone afterwards. Fails on main (nothing is protected; release builds also lose the cause). - The release test also checks the protected count returns to its baseline. - The teardown fixture counts the files the worker's writes would have produced: jobs released by the teardown never run, so the count must be zero, and a run where the pool was not actually parked fails instead of passing vacuously (without UV_THREADPOOL_SIZE=2 it reports 12).
|
Pushed 1655472 (tests only) after going over the coverage once more:
Fix commit unchanged. |
|
Re-checked on current main (97a4363) after the teardown rework in #38299. Part of this is covered now, part is not, so this stays open but should shrink on rebase. Covered by #38299: the crash in the title. A job's off-thread half, the
Not covered: the in-flight liveness half. Nothing keeps the signal's wrapper alive while the operation is pending, so "readFile aborted while in flight keeps its reason when the caller holds nothing" still fails on main (the protected |
Symptom
fs.readFile/fs.promises.readFile/writeFile/appendFilecalled with{ signal }inside a worker that is torn down (terminate(), process.exit(), uncaught error) while those operations are still queued on the thread pool crash the process once the pool gets to them. Same thing on the main thread underBUN_DESTRUCT_VM_ON_EXIT=1. Affects every platform (these ops useAsyncFSTaskeverywhere).Repro:
test/js/node/fs/abort-signal-read-write-file-worker-teardown-fixture.ts, run withUV_THREADPOOL_SIZE=2. It parks both pool threads in areadFile()of a FIFO (theopen("w")handshake proves they are inside those reads), has a worker queue readFile/writeFile/appendFile calls with three kinds of signals behind them, terminates the worker, then closes the FIFOs. On main, a debug build dies with both of these at once (one per pool thread):with the stack
<bun_runtime::node::fs::args::ReadFile as Drop>::drop->ExternalShared<AbortSignal>::drop->WebCore__AbortSignal__unref->WebCore::AbortSignal::~AbortSignal()on a pool thread. The first is aRELEASE_ASSERT, so release builds die the same way for a signal that has abort listeners.Cause
args::ReadFile/args::WriteFileowned theAbortSignalRef(and the pending-activity count), and the arguments are part of the job's off-thread half.job.rsreleases a job's JS side on the VM's own thread at teardown and leaves the off-thread half to whoever holds it; for a job still queued on the pool that is a pool thread, after the VM, its heap and the signal's wrapper are gone. The job's ref is the last one by then, so~AbortSignal()runs on the pool thread against a dead VM:~EventTargettripsEventListenerMap's thread check (release assert), and eachJSEventListenerreleases aJSC::Weakinto the freed heap;m_reasonreleases aJSC::Weakinto the freed heap (heap corruption in release);AbortSignal.timeout():cancelTimer()->AbortSignal__Timeout__deinitresolves the VM through the calling thread's thread-local, which a pool thread does not have;Fix
The arguments keep only a pointer to the signal, and the only thing the operation does with it is poll
aborted(). The flags byte behindaborted()isstd::atomicnow (relaxed; GC marker threads were already reading it throughJSAbortSignalOwner), so the pool's poll is a plain atomic load instead of the racy byte read it was before.Keeping the signal alive while the operation is pending is
args::SignalHold: GC protection on the wrapper plus the pending-activity count the code already took (it is what marks a timeout signal as observed). It is taken when the arguments are parsed and released on the JS thread in every case: a synchronous call drops it with the arguments, andAsyncFSTask::createmoves it onto the job's JS side (AsyncFSJs.signal), which the completion releases, orJobList::release_all_jsat teardown, same as the promise. The off-thread half of a job freed after its VM is gone holds nothing of the signal any more. No allocation, no listener.Why the retention is a protection on the wrapper rather than a ref of our own held on the JS side: teardown releases the JS sides (
release_all_js) beforeVmHandle::close()waits for bodies still running on the pool. Unprotecting only makes the wrapper collectible, and nothing collects it before the VM is destroyed, which happens after those bodies have finished, so a body still polling keeps reading a live signal; that is the same thing every other job's off-thread pointers rely on. Dropping what might be the last ref at that point would free the signal under a running body (a signal whose wrapper was already collected mid-operation, e.g. a throwaway controller).ReadFile/WriteFileno longer need aDropimpl; the threeReadFile::default()-then-assign sites that existed because of it are struct literals now (clippy'sfield_reassign_with_defaultfires on them otherwise).Tests
In
test/js/node/fs/promises.test.js:UV_THREADPOOL_SIZE=2it reports 12, so a run that did not actually park the pool fails instead of passing vacuously). Fails on main as above (a release build of main dies in ~170ms), passes with the fix. Runs the child withMalloc=1so the heap side is visible to ASAN as well.heapStats().protectedObjectTypeCounts), after two GCs the rejection still carries its cause, and afterwards the protection is gone. Fails on main: nothing is protected, and on a release build of main the cause also comes backundefined(20/20; the reason is only reachable through the wrapper, which nothing kept alive).Also green locally on the debug/ASAN build:
fs.test.ts,promises.test.js,test/js/web/abort/, the fetch abort tests,worker-refused-completion.test.ts, the Nodetest-fs-{promises-,}readfile*/writefile*files;cargo clippy -p bun_runtime, clang-format on the header, and acargo checkof the Windows target.Related but different: #36259 and #35021 add more
aborted()polls between chunks and compose with this. #36983 / #37170 / #34154 predate #37075's job model and address in-flight or blocked work; #37170's refused-post disposal also leaks the signal ref withManuallyDropinstead of releasing it, which this supersedes (its signal arm can be dropped on rebase).