Skip to content

worker: fence async crypto (AnyTaskJob) completions against terminate() freeing the VM - #35154

Closed
robobun wants to merge 3 commits into
mainfrom
farm/b8899a91/fix-anytaskjob-terminate-uaf
Closed

robobun wants to merge 3 commits into
mainfrom
farm/b8899a91/fix-anytaskjob-terminate-uaf

Conversation

@robobun

@robobun robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

What

worker.terminate() while an async crypto.scrypt() / crypto.pbkdf2() (or any other AnyTaskJob: HKDF, prime gen, key-pair gen, sign, DH, Bun.secrets, Bun.zstd*) is running on the work pool freed the worker's VirtualMachine and its JSC heap while the pool thread was still touching both:

  • AnyTaskJob::run_task read vm.global and vm.event_loop_shared() off a raw backref when posting its completion
  • ctx.run() itself reads inputs from JSC-backed ArrayBuffers (StringOrBuffer password/salt) and, for Scrypt, writes its derived key into a JSC ArrayBuffer allocated in init()

WebWorker::shutdown frees the JSC heap (teardownJSCVM) and then the VM storage, so a pool thread still inside BoringSSL's scrypt wrote its output into freed memory. The next worker to reuse that allocation saw the corruption as a garbage ArrayAllocationProfile in llint_op_new_array (aarch64 release lanes), or the completion enqueue read the freed EventLoop directly:

heap-use-after-free  READ of size 8  thread T15 (Bun Pool 2)
  #0 event_loop_shared                                    src/jsc/VirtualMachine.rs:740
  #1 AnyTaskJob<crypto::CallbackCtx<Scrypt>>::run_task    src/jsc/any_task_job.rs:146
freed by thread (Worker):  WebWorker::shutdown  src/jsc/web_worker.rs:1390

Fix

A per-VM Arc<RwLock<bool>> gate. AnyTaskJob::create clones it; run_task runs ctx.run() and the enqueue_task_concurrent push under a read lock. WebWorker::shutdown closes the gate (write lock) right next to the existing ScriptExecutionContext::markTerminating / JSCTaskScheduler::markShuttingDown fences, before draining the concurrent queue, before JSC teardown, and before deallocating the VM. The write lock waits out every in-flight reader so the work body finishes and its push is visible to the drain; a job the pool picks up after close() observes it and skips its body entirely (matching Node's behaviour of draining in-flight libuv work on env teardown).

Skipped jobs intentionally leak their ctx (it holds Strong JSC handles into the dead VM's heap, and poll.unref would touch the freed event loop). This matches the fate of tasks already queued on a terminated worker's never-drained concurrent queue. The gate Arc itself is released on both paths so it does not accumulate.

Scope

This is the focused AnyTaskJob slice of #32071 / #34154 (which cover the full cross-thread producer class). The other producers (FetchTasklet, WorkTask, ConcurrentPromiseTask, AsyncFSTask, napi, watchers) still have the analogous defect and stay with those PRs; this change keeps the diff small so the crypto segfault can land independently. The AnyTaskGate introduced here is reusable by those call sites if the wider change is further delayed.

Verification

New test in test/js/web/workers/worker-terminate-lifetime.test.ts:

  • fail-before (USE_SYSTEM_BUN=1): segfault at 0xFFFFFFFF00000000, "multiple threads are crashing"
  • fail-after (bun bd, ASAN): passes
  • fail-after (release x64, 12-round original repro): 12/12 clean

Existing test/js/node/crypto/{pbkdf2,scrypt,hkdf-callback-null}.test.ts and node-crypto.test.js (202 tests, covers the extern_crypto_job! set) all pass.


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/web/workers/worker-terminate-lifetime.test.ts

AnyTaskJob::run_task (the work-pool callback for async node:crypto jobs,
Bun.secrets, and Bun.zstd*) held a raw backref to the scheduling
VirtualMachine and dereferenced it on the pool thread to read vm.global
and vm.event_loop_shared() when posting its completion. When that VM was
a worker that terminate() freed (WebWorker::shutdown dealloc's the VM
storage) while a long scrypt/pbkdf2 was still running in BoringSSL, both
reads were a cross-thread heap-use-after-free. Release builds segfaulted;
ASAN reported the UAF on a Bun Pool thread at event_loop_shared.

Fix: a per-VM Arc'd RwLock<bool> gate that each AnyTaskJob clones at
create time, plus a snapshot of global/event_loop so run_task never
reads a VM field. WebWorker::shutdown closes the gate (write lock) right
next to the existing ScriptExecutionContext/JSCTaskScheduler fences,
before draining the concurrent queue and freeing the VM: the write lock
waits out any in-flight enqueue reader so its push is visible to the
drain, and completions landing after close() observe it and drop their
task instead of touching freed memory. Rejected jobs leak their ctx
(it holds Strong JSC handles into the dead VM's heap), matching the fate
of tasks already queued on a terminated worker's never-drained queue.
@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 14 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b5f88a28-440e-4cfc-a50e-f8fc440df18c

📥 Commits

Reviewing files that changed from the base of the PR and between 47597ab and 7b7b146.

📒 Files selected for processing (4)
  • src/jsc/VirtualMachine.rs
  • src/jsc/any_task_job.rs
  • src/jsc/web_worker.rs
  • test/js/web/workers/worker-terminate-lifetime.test.ts

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

@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:53 PM PT - Jul 22nd, 2026

❌ @robobun, your commit 7b7b146 has 3 failures in Build #77982 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35154

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

bun-35154 --bun

@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced with the script in the test (6 scrypt/pbkdf2 lanes in a worker, then terminate()): stock release build segfaults at 0xFFFFFFFF00000000 with "multiple threads are crashing" on the first or second round; debug+ASAN reports the heap-use-after-free on a Bun Pool thread at VirtualMachine::event_loop_shared. With this change the same scenario passes under bun bd (ASAN) and the 12-round repro runs clean on a local release build.

12f4032 extends the gate to cover ctx.run() as well as the enqueue: aarch64 release CI (build 77950) showed the enqueue-only fence still left Scrypt writing its derived key into a freed JSC ArrayBuffer, corrupting the next worker's heap. 7b7b146 adds the same close() to global_exit() (the destruct-on-exit mirror of worker shutdown) and opts the test subprocess out of LSan for the documented stranded-task leak.

CI (build 77982): the new test is green on every lane including aarch64 release and x64-asan. Remaining red is unrelated to this diff:

  • test-net-connect-memleak.js / test-gc-http-client-connaborted.js: FinalizationRegistry GC-timing assertions, also intermittently red on main builds 77601 and 77580; neither touches AnyTaskJob. Both pass locally.
  • darwin 26 aarch64 step failed outside the runner ("The number of VMs exceeds the system limit"): CI infra.
  • all other lanes are retry-pass flakes.

Ready for review.

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Worker lifetime: carry a generation token with cross-thread VM handles (follow-up to #32071) #32073 - Explicitly names AnyTaskJob as a producer needing a generation-token gate against dead VMs; this PR's AnyTaskGate is a direct partial implementation of that design
  2. panic: Segmentation fault at address 0xD — "multiple threads are crashing" under Worker spawn/terminate churn (1.3.14, long-running server) #31880 - Segfault under worker spawn/terminate churn; AnyTaskJob completing against a freed VM is one concrete crash path this PR closes
  3. Worker create+terminate cycle aborts process after ~100k–900k iterations on macOS arm64 #30421 - Worker create+terminate cycle aborts after ~100k iterations; in-flight AnyTaskJob reading freed VM memory is a plausible cause this PR prevents

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #32073
Fixes #31880
Fixes #30421

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix use-after-free when worker.terminate() races in-flight fetch/work-pool completions #34154 - Also fences AnyTaskJob completions against worker.terminate() via a ShutdownGate in the same files (any_task_job.rs, VirtualMachine.rs, web_worker.rs)
  2. Don't enqueue to a terminated worker's freed event loop from other threads #32071 - Broader fix that protects all cross-thread producers (including AnyTaskJob) against terminated worker event loops via a VmHandle/LoopHandle registry

🤖 Generated with Claude Code

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Re the duplicate flag: #32071 is already called out in the PR body. #34154 is the broader VM-pin redesign (47 files, currently merge-conflicting) that covers the full producer class including AnyTaskJob; this PR is the 4-file slice that closes the AnyTaskJob crash path (scrypt/pbkdf2 etc.) on its own so it can land independently. Happy to close if #34154 is being revived instead.

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

No bugs found, but this is cross-thread VM-lifetime / memory-safety code (RwLock fence between work-pool completions and worker VM teardown, with an intentional-leak path) and one slice of the broader #32071 design — worth a human sign-off.

What was reviewed:

  • Fence ordering: close() sits after markTerminating/markShuttingDown and before the concurrent-queue drain / step-5 vm.destroy(); has_terminated is set only in destroy(), so the debug assertion in enqueue_task_concurrent cannot fire under the read lock.
  • run_task no longer reads any VM field on the pool thread; ctx.run(global) still receives a raw pointer that may dangle post-terminate, matching the existing trait contract that impls never dereference it.
  • Rejected-path cleanup: the heap ConcurrentTask node is freed and the gate Arc is drop_in_place'd; the documented ctx/poll leak mirrors tasks stranded on a terminated worker's undrained queue.
  • any_task_gate init via addr_of_mut!().write (zero-valid Option slot) and take() in destroy(); main-VM global_exit never closes the gate, which is the pre-existing scope gap deferred to #32071.
Extended reasoning...

Overview

Adds a per-VM Arc<RwLock<bool>> gate (AnyTaskGate) so AnyTaskJob work-pool completions can detect that their owning worker VM was freed by terminate() and drop instead of pushing into a freed EventLoop. Touches VirtualMachine (new field + init/destroy wiring + accessor), any_task_job.rs (gate type, snapshot of global/event_loop at create time, rewritten run_task), web_worker.rs (single close() call in shutdown), and a new regression test.

Security risks

None user-facing. This is a UAF fix; the closed-gate path intentionally leaks the job's ctx (Strong JSC handles into a dead heap) and KeepAlive, which is a bounded per-terminate leak equivalent to the existing fate of tasks stranded on a terminated worker's concurrent queue.

Level of scrutiny

High. Cross-thread lifetime + lock-ordering reasoning around VM teardown is exactly the category REVIEW.md calls out as most-blocked. The RwLock-as-fence pattern is sound as written (write lock in close() waits out in-flight readers so their push is visible to the subsequent drain; later readers observe closed), and it mirrors the adjacent ScriptExecutionContext::markTerminating shape. But the choice of an Arc<RwLock> gate here vs. #32071's VM-handle registry, and the intentional-leak decision, are design calls a maintainer should ratify.

Other factors

  • enqueue_task_concurrent takes &self and its debug-only vm_ref().has_terminated read is ordered safe (has_terminated is set in destroy(), which is step 5, after close()).
  • bun_threading::RwLock<T> has the parking_lot-shaped read()/write() guards the code assumes.
  • The gate is never closed for the main VM (only WebWorker::shutdown calls close()); destroy() just drops the Arc. That's the pre-existing gap the PR explicitly scopes out to #32071, not a regression.
  • Test follows harness conventions (subprocess, bunEnv, concurrent pipe drain, combined-object assertion, awaits the "up" message before terminate) and the PR reports fail-before/pass-after under ASAN.

Several AnyTaskJobCtx::run impls touch the owning VM's JSC heap on the
pool thread: Scrypt writes its derived key into a JSC ArrayBuffer (the
backing Scrypt::result borrows), and both Scrypt and PBKDF2 read their
password/salt from StringOrBuffer-wrapped JS buffers. With only the
enqueue fenced, a terminate() that won the race freed the JSC heap
(teardownJSCVM) while a pool thread was still writing the scrypt output
into it; the next worker to reuse that memory saw the corruption as a
garbage ArrayAllocationProfile in llint_op_new_array (aarch64 release
lanes on CI).

Hold the gate's read lock across ctx.run() as well as the enqueue, so
close() in WebWorker::shutdown waits out the whole in-flight work body
before JSC teardown. A job the pool picks up after close() skips its
body entirely. With the VM proven live under the lock, run_task can read
vm.global / vm.event_loop_shared() directly again, so the snapshot fields
are dropped.
Comment thread src/jsc/VirtualMachine.rs
…out of LSan

VirtualMachine::global_exit() under BUN_DESTRUCT_VM_ON_EXIT mirrors
WebWorker::shutdown()'s fence-then-drain sequence and reaches
Zig__GlobalObject__destructOnExit (frees the JSC heap) and destroy(),
so the same AnyTask gate fence belongs here too; without it an async
scrypt running at process.exit() would write its output into a freed
ArrayBuffer. Placed right after JSCTaskScheduler__markShuttingDown,
matching the worker path.

The new test's subprocess is opted out of LSan: jobs that race into
concurrent_tasks before close() end up requeued into EventLoop.tasks at
deinit, whose buffer becomes unreachable when the worker VM is
raw-dealloc'd. The leak is bounded per terminated worker and is the same
accounting as tasks already stranded on a terminated worker's queue.

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

Thanks for addressing the global_exit() mirror in 7b7b146 — that closes the sibling path. I didn't find further correctness issues, but this is cross-thread VM-lifetime code with an intentional-leak trade-off and an open question of whether to land this slice vs. revive #34154, so deferring to a maintainer.

What was reviewed:

  • AnyTaskGate read/write-lock ordering vs. the shutdown() step sequence — close() is placed before release_queued_tasks_for_shutdown and teardownJSCVM, so in-flight readers finish and their enqueue is visible to the drain.
  • The closed-gate leak path: drop_in_place on the job's gate field releases the Arc clone; ctx/poll are leaked as documented (touching either would UAF the dead VM). Matches the fate of already-queued concurrent tasks.
  • close()'s write lock will block worker shutdown() (and thus terminate() resolution) until every in-flight KDF finishes — for the test's N=1<<14 scrypt that's tens of ms, but user-controllable params could stretch it. Worth a maintainer eye on whether that latency is acceptable vs. Node's drain semantics.
Extended reasoning...

Overview

Adds a per-VM Arc<RwLock<bool>> fence (AnyTaskGate) so work-pool AnyTaskJob bodies (ctx.run() + concurrent-queue enqueue) either complete entirely before the owning VM is torn down, or are skipped. Touches src/jsc/any_task_job.rs (new gate type + run_task rework), src/jsc/VirtualMachine.rs (new field + accessor, global_exit() close, destroy() cleanup), src/jsc/web_worker.rs (one close() call in shutdown()), and a new regression test.

Security risks

None user-facing; this is an internal lifetime fix. The change removes a heap-use-after-free (pool thread writing scrypt output into a freed JSC ArrayBuffer / reading a freed EventLoop).

Level of scrutiny

High. Per REVIEW.md this sits squarely in "Native code: memory safety (the most-blocked category)" — cross-thread refcounts, RwLock ordering against a documented multi-step teardown sequence, an intentional leak on the closed-gate path with an LSan opt-out in the test, and unsafe raw-pointer field access on the pool thread. The placement of close() relative to markTerminating/markShuttingDown/release_queued_tasks_for_shutdown/teardownJSCVM is load-bearing and mirrors two existing fences, but the write-lock-blocks-until-KDF-finishes behaviour is a new latency characteristic for terminate() that a human should sign off on.

Other factors

  • My earlier finding (missing close() in the global_exit() mirror under BUN_DESTRUCT_VM_ON_EXIT) was addressed in 7b7b146 and the inline thread is resolved.
  • The author explicitly flags this as competing with #34154 (broader 47-file redesign) and offers to close if that PR is revived — a maintainer needs to make that call.
  • Test has fail-before/fail-after evidence and covers both scrypt and pbkdf2; LSan opt-out is documented and bounded per terminated worker.
  • bun_threading::RwLock is the in-tree primitive (not std::sync), matching repo convention.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Generalised into #35767 using the enqueue-by-identifier route (same serialization point as markTerminating(), no wait on in-flight KDFs).

@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

test/js/node/test/parallel/test-crypto-prime.js went red on main in builds 84904 (alpine x64, segfault) and 85363 (debian x64-asan, heap-use-after-free) with the same AnyTaskJob UAF this PR fixes:

#11 enqueue_task_concurrent () at src/jsc/event_loop.rs:1003
#12 run_task<bun_runtime::node::crypto::GeneratePrimeJob::ExternCtx> () at src/jsc/any_task_job.rs:147

The test's worker block (line 313) calls generatePrime(2048, ...) then process.exit(42), so the pool thread's completion enqueue races worker teardown. GeneratePrimeJob is routed through extern_crypto_job! → AnyTaskJob, so it's already in this PR's scope.

@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main as #36817 (this one had gone conflicting) with a Buffer-input test that exercises the PKCS5_PBKDF2_HMAC / EVP_PBE_scrypt ArrayBuffer-read face in addition to the completion-enqueue face.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as superseded by #37075 (9d519e8), which replaced AnyTaskJob and src/jsc/any_task_job.rs with the bun_jsc::Job carrier and the per-VM handle that VirtualMachine::teardown() closes before the JSC heap is destroyed. In-flight pool work is now waited for or released during teardown, so the fence this PR adds has nothing left to guard.

Verified on a debug + ASAN build of current main (f426a8e): the test from this PR and the one from #36817 both pass, two runs each, with no sanitizer output. Main also carries direct coverage for pbkdf2/scrypt jobs being refused after terminate() in test/js/web/workers/worker-refused-completion.test.ts. #36817, which fixed the same race, is closed for the same reason.

@robobun robobun closed this Aug 13, 2026
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