Skip to content

worker: post every cross-thread completion by ScriptExecutionContext id so terminate() cannot UAF the freed VM - #35767

Closed
robobun wants to merge 7 commits into
mainfrom
farm/85af00f7/root-b-shutdown-fence
Closed

worker: post every cross-thread completion by ScriptExecutionContext id so terminate() cannot UAF the freed VM#35767
robobun wants to merge 7 commits into
mainfrom
farm/85af00f7/root-b-shutdown-fence

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Problem

worker.terminate() frees the worker's VirtualMachine box while work already handed to a process-global thread (WorkPool, HTTP thread, bundle thread) is still in flight. The later completion posts back through a BackRef<EventLoop> / &'static VirtualMachine / *const JSGlobalObject captured at schedule time, every one of which is a pointer into the freed box or the freed JSC heap.

panic: Segmentation fault at address 0x8
Args: "bun" "/tmp/rootb-quick.mjs"
Features: workers_spawned(9) workers_terminated(8)

All reproduced cross-thread UAFs in this class converge on EventLoop::enqueue_task_concurrent (src/jsc/event_loop.rs:997). A check inside the funnel cannot help: &self there is already inside the freed box. Several callers also "guard" with an off-thread vm.is_shutting_down() read, which is itself a read of freed memory.

Fix

Off-thread jobs now carry the originating ScriptExecutionContextIdentifier (a u32; cannot dangle) instead of a raw event-loop/VM pointer, and post through

ScriptExecutionContextIdentifier::post_concurrent_task(id, task) -> bool

backed by the same allScriptExecutionContextsMapLock + isTerminating() gate that ScriptExecutionContext::postTaskTo already uses for C++ posters. WebWorker::shutdown already calls markTerminating() (which takes that lock) before it drains the concurrent queue and frees the VM, so every Rust poster now serializes into one of two cases: either its whole critical section ran first (task enqueued; the subsequent release_queued_tasks_for_shutdown() drain observes it), or markTerminating() ran first (poster gets false and runs its abandon path without touching the VM).

Converted:

  • The three generic helpers WorkTask<C>, ConcurrentPromiseTask<C>, AnyTaskJob<C> (covers Bun.file/Bun.write, Transpiler.transform, Glob.scan, crypto.pbkdf2/scrypt/generateKeyPair, Bun.zstdCompress, Bun.secrets, image pipeline).
  • ConcurrentCppTask (crypto.subtle): the trailing unref_concurrently() now goes through the id under the same lock.
  • Direct callers: FetchTasklet, PasswordJob, NativeZlib/NativeBrotli/NativeZstd, AsyncFSTask / NewAsyncCpTask / AsyncReaddirRecursiveTask, S3HttpSimpleTask / S3HttpDownloadStreamingTask, Archive::AsyncTask, JSBundleCompletionTask, TranspilerJob.

Abandon path: when post_concurrent_task returns false, free the freshly-allocated ConcurrentTask node (ownership was not transferred) and any pure-Rust payload; leak the job box when it holds Strong/JSPromiseStrong handles into the dead JSC heap (bounded: one per terminated worker per in-flight op).

Design doc: docs/ROOT-B-SHUTDOWN-FENCE.md.

Verification

New test terminate() while cross-thread WorkPool completions are in flight does not UAF on enqueue in test/js/web/workers/worker-terminate-lifetime.test.ts: a subprocess repeatedly creates a worker that arms one in-flight op of every converted source (Bun.write/Bun.file, fs.promises.*, crypto.pbkdf2/scrypt/generateKeyPair, Bun.zstdCompress, Bun.password.hash, zlib.deflate/gzip, Bun.Transpiler.transform, Glob.scan, crypto.subtle.digest, Bun.build, fetch) and is terminated once armed.

  • Stock canary: SIGSEGV at 0x8 on teardown ~9 (release); heap-use-after-free on teardown 1-3 (debug+ASAN).
  • This branch: 20/20 teardowns clean under debug+ASAN; test passes.

Standalone harness repro/rootB-verify/verify.mjs arms the same set x100 for the manual gate (ROOT-B VERIFY: PASS (10 teardowns) under ASAN here; default 100 iterations).

Relationship to open PRs

Generalises the per-callsite patches in #35158 (Bun.build), #35154 (AnyTaskJob) and #35156 (Bun.password) into one chokepoint using the identifier-route design. Uses the same C++ ScriptExecutionContext__postConcurrentTask shape as #35158.

Explicitly out of scope (not enqueue-shaped; need their own fixes): nested-worker child-init reading a freed parent VM, node:quic finalizer ordering, RedisClient::finalize, Bun.SQL handle crashes during terminating-VM JS execution, and the napi_async_work / fs.watch / shell-task / AsyncModule-wake posters and the Windows-only WriteFileWindows/CopyFileWindows AsyncMkdirp completions (same shape, follow-up).


no test proof · iteration 1 · 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

Fixes #32073
Towards #33936
Towards #15964

worker.terminate() frees the VirtualMachine box while work already
handed to a process-global thread (WorkPool, HTTP thread, bundle
thread) is still in flight; the later completion posted back through a
captured BackRef<EventLoop> / &VirtualMachine, which is a pointer into
the freed box. Every still-reproducing cross-thread UAF in this class
converges on EventLoop::enqueue_task_concurrent, and several callers
'guard' with an off-thread vm.is_shutting_down() read that is itself a
read of freed memory.

Off-thread jobs now carry the originating ScriptExecutionContextIdentifier
(a u32, cannot dangle) and post through
ScriptExecutionContextIdentifier::post_concurrent_task, which looks the
context up and enqueues under allScriptExecutionContextsMapLock, the
same lock markTerminating() (already called in WebWorker::shutdown
before the VM dealloc) takes to set the terminating flag. Either the
poster's critical section ran first (task enqueued; shutdown's drain
reclaims it) or markTerminating ran first (poster gets false and runs
its abandon path without touching the VM).

Converted: the three generic helpers (WorkTask, ConcurrentPromiseTask,
AnyTaskJob, covering Bun.file/Bun.write, Transpiler.transform,
Glob.scan, pbkdf2/scrypt/generateKeyPair/zstd/Secrets), plus the
direct callers FetchTasklet, PasswordJob, NativeZlib/Brotli/Zstd,
AsyncFSTask/NewAsyncCpTask/AsyncReaddirRecursiveTask,
S3HttpSimpleTask/S3HttpDownloadStreamingTask, Archive AsyncTask,
JSBundleCompletionTask, TranspilerJob, and ConcurrentCppTask's
unref_concurrently (WebCrypto).

Design doc: docs/ROOT-B-SHUTDOWN-FENCE.md.
Verify harness: repro/rootB-verify/verify.mjs (100x arm-every-source +
terminate; stock canary SIGSEGVs on teardown 1, debug+ASAN heap-UAF on
teardown 1-3; 10/10 clean after).
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The PR introduces ScriptExecutionContextIdentifier-based fencing for cross-thread task completion during worker shutdown. It updates task producers across JSC, filesystem, networking, bundling, cryptography, compression, and fetch paths, adds abandonment cleanup, and provides regression verification tooling and tests.

Worker shutdown fence

Layer / File(s) Summary
Context identifier fence and FFI
src/jsc/JSGlobalObject.rs, src/jsc/bindings/*, src/jsc/virtual_machine_exports.rs, src/event_loop/ConcurrentTask.rs
Adds identifier-based liveness, concurrent posting, event-loop unref helpers, C++ accessors, and cleanup for unqueued callback tasks.
Task completion migration
src/jsc/*, src/runtime/api/*, src/runtime/crypto/*, src/runtime/node/*
Replaces stored VM or event-loop references with context identifiers and routes asynchronous completions through fenced posting with failure cleanup.
Fetch and S3 completion fencing
src/runtime/webcore/fetch/*, src/runtime/webcore/s3/*
Uses context liveness and post results to handle fetch, HTTP, S3, drain, response, and stream callbacks during teardown.
Shutdown verification and documented coverage
docs/ROOT-B-SHUTDOWN-FENCE.md, repro/rootB-verify/*, test/js/web/workers/worker-terminate-lifetime.test.ts
Documents the failure mode and fencing rules, adds a repeated termination runner, and adds an ASAN worker-lifetime regression test.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#34154 — Refactors the same worker-shutdown completion paths toward shutdown-safe posting.
  • oven-sh/bun#34278 — Changes shutdown ordering around markTerminating(), which this PR’s posting gate relies on.
  • oven-sh/bun#35155 — Addresses the related in-flight compression work during worker termination.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning [#32073, #33936] The identifier-token fence addresses stale completion delivery, but TranspilerJob still reads VM-owned state on the pool thread before any safe gate. Move RuntimeTranspilerStore/TranspilerJob storage out of the VM allocation or wait for in-flight transpiler jobs before freeing the VM.
✅ Passed checks (3 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The docs, test, and runtime helper changes all support the shutdown-fence work; no unrelated feature work stands out.
Title check ✅ Passed The title clearly summarizes the main change: switching cross-thread completions to ScriptExecutionContext identifiers to avoid UAF on termination.
Description check ✅ Passed The description covers the change and verification, though it uses custom section headings instead of the template's exact labels.

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

Comment thread src/jsc/ConcurrentPromiseTask.rs Outdated
Comment thread src/jsc/ConcurrentPromiseTask.rs Outdated
Comment thread src/jsc/ConcurrentPromiseTask.rs Outdated
Comment thread src/jsc/CppTask.rs Outdated
Comment thread src/jsc/CppTask.rs Outdated
Comment thread src/jsc/JSGlobalObject.rs Outdated
Comment thread src/jsc/JSGlobalObject.rs Outdated
Comment thread src/jsc/JSGlobalObject.rs Outdated
Comment thread src/jsc/JSGlobalObject.rs
Comment thread src/jsc/JSGlobalObject.rs Outdated
Comment thread src/jsc/RuntimeTranspilerStore.rs Outdated
Comment thread src/jsc/RuntimeTranspilerStore.rs Outdated
Comment thread src/jsc/WorkTask.rs Outdated
Comment thread src/jsc/WorkTask.rs Outdated
Comment thread src/jsc/WorkTask.rs Outdated
Comment thread src/jsc/any_task_job.rs Outdated
Comment thread src/jsc/any_task_job.rs Outdated
Comment thread src/jsc/any_task_job.rs Outdated
Comment thread src/jsc/bindings/ScriptExecutionContext.cpp Outdated
Comment thread src/jsc/bindings/ScriptExecutionContext.cpp
Comment thread src/jsc/bindings/ScriptExecutionContext.cpp Outdated
Comment thread src/jsc/virtual_machine_exports.rs
Comment thread src/jsc/JSGlobalObject.rs
Comment thread src/jsc/JSGlobalObject.rs
Comment thread src/jsc/JSGlobalObject.rs
Comment thread src/jsc/any_task_job.rs
Comment thread src/jsc/bindings/ScriptExecutionContext.cpp
Comment thread src/jsc/virtual_machine_exports.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed in 0cdab21:

  • comment-cop: trimmed the duplicated per-call-site explanation across 16 files (net -120 lines). The remaining flags are on ScriptExecutionContextIdentifier::post_concurrent_task's API doc (the ownership contract every converted caller depends on) and the C++ ScriptExecutionContext__postConcurrentTask ordering comment (mirrors the existing postTaskTo comment in the same file); those stay.
  • RuntimeTranspilerStore SAFETY comment (claude review): correct, is_alive() releases the lock before returning. Rewrote the comment to state the residual race honestly; run() has the same unfenced (*vm).* exposure so an honest comment is the proportionate fix.
  • id == 0 guard (claude review): added if (!id) return false; to the three new C++ lookups, matching getScriptExecutionContext.
  • AnyTaskJob off-thread VM deref (claude review): now captures global on the JS thread in create() and passes job.global to ctx.run(), so the pool thread no longer touches the BackRef<VirtualMachine>.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:14 PM PT - Jul 25th, 2026

@robobun, your commit 96e26ed has 1 failures in Build #81483 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35767

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

bun-35767 --bun

Comment thread src/runtime/api/js_bundle_completion_task.rs
Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts Outdated
Comment thread repro/rootB-verify/verify.mjs
…, root test scratch under harness tempDir

COMPLETION_VTABLE.enqueue_task_concurrent (the bundler's off-thread
onLoad/onResolve plugin dispatch) was still derefing jsc_event_loop;
route it through context_id.post_concurrent_task like
complete_on_bundle_thread. The jsc_event_loop field (and the event_loop
parameter on create_and_schedule_completion_task) are now dead and
removed.

Worker test scratch dir rooted under body.mjs's directory (the harness
tempDir), so 'using dir' reclaims it instead of leaking into $TMPDIR.
repro/verify.mjs now owns one scratch root and removes it on exit.
Test's Bun.build grows a plugin so the COMPLETION_VTABLE path is armed.
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

ae2db33:

  • COMPLETION_VTABLE.enqueue_task_concurrent: converted to context_id.post_concurrent_task, dropped the dead jsc_event_loop field and the event_loop parameter. Test's Bun.build now has a plugin so this path is armed under ASAN.
  • test tempdir leak: worker scratch dir now rooted under body.mjs's directory (the harness tempDir), so using dir reclaims it.
  • repro/ + docs/ROOT-B-SHUTDOWN-FENCE.md: kept as the originating brief requested them at those paths; fixed the leak in the repro harness. Replied in-thread; glad to move or drop if preferred.

Comment thread src/runtime/node/node_zlib_binding.rs Outdated
Comment thread src/runtime/node/node_fs.rs
Comment thread src/jsc/WorkTask.rs Outdated
…m_callback, drop dead event_loop fields

- node_zlib_binding: wrap do_work() in is_alive() (writes into the pinned
  JS ArrayBuffer), same best-effort skip as AnyTaskJob::run_task.
- ConcurrentTask::destroy_from_callback: from_callback allocates two
  boxes (outer ConcurrentTask + inner ManagedTask); the abandon paths at
  FetchTasklet deref_from_thread/on_write_request_data_drain and
  NewAsyncCpTask now reclaim both.
- WorkTask / ConcurrentPromiseTask: event_loop field was write-only
  after on_finish moved to context_id; removed with its imports.
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

a9a1475 addresses the round-3 nits:

  • zlib do_work() parity: wrapped in is_alive(), same best-effort skip as AnyTaskJob::run_task.
  • from_callback inner-box leak: added ConcurrentTask::destroy_from_callback that reclaims both the outer node and the inner ManagedTask; the three abandon sites now use it.
  • dead event_loop fields: removed from WorkTask/ConcurrentPromiseTask along with the now-unused EventLoop/VirtualMachine/BackRef imports.

Comment thread src/event_loop/ConcurrentTask.rs
Comment thread docs/ROOT-B-SHUTDOWN-FENCE.md
Comment thread src/runtime/api/JSBundler.rs Outdated

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

Actionable comments posted: 2

🤖 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 `@repro/rootB-verify/verify.mjs`:
- Line 14: Validate ROOTB_ITER after converting it in the ITERATIONS
initialization so it must be a finite positive safe integer; reject zero,
negatives, NaN, fractions, and Infinity before the teardown loop runs. Preserve
the existing default of 100 when the environment variable is unset, and fail
clearly for invalid values.

In `@test/js/web/workers/worker-terminate-lifetime.test.ts`:
- Around line 194-203: Update the scratch path setup around the module URL in
the worker test to convert import.meta.url with fileURLToPath before passing it
to path.dirname. Add the required node:url import and preserve the existing
scratch directory and cleanup behavior.
🪄 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: 3724e752-b2b6-46d2-90b8-d4eceaf090d9

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and b32298f.

📒 Files selected for processing (29)
  • docs/ROOT-B-SHUTDOWN-FENCE.md
  • repro/rootB-verify/verify.mjs
  • repro/rootB-verify/worker-body.mjs
  • src/event_loop/ConcurrentTask.rs
  • src/jsc/ConcurrentPromiseTask.rs
  • src/jsc/CppTask.rs
  • src/jsc/JSGlobalObject.rs
  • src/jsc/RuntimeTranspilerStore.rs
  • src/jsc/WorkTask.rs
  • src/jsc/any_task_job.rs
  • src/jsc/bindings/EventLoopTaskNoContext.cpp
  • src/jsc/bindings/EventLoopTaskNoContext.h
  • src/jsc/bindings/ScriptExecutionContext.cpp
  • src/jsc/virtual_machine_exports.rs
  • src/runtime/api/Archive.rs
  • src/runtime/api/JSBundler.rs
  • src/runtime/api/js_bundle_completion_task.rs
  • src/runtime/crypto/PasswordObject.rs
  • src/runtime/node/node_fs.rs
  • src/runtime/node/node_zlib_binding.rs
  • src/runtime/node/zlib/NativeBrotli.rs
  • src/runtime/node/zlib/NativeZlib.rs
  • src/runtime/node/zlib/NativeZstd.rs
  • src/runtime/server/HTMLBundle.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • src/runtime/webcore/s3/client.rs
  • src/runtime/webcore/s3/download_stream.rs
  • src/runtime/webcore/s3/simple_request.rs
  • test/js/web/workers/worker-terminate-lifetime.test.ts
💤 Files with no reviewable changes (1)
  • src/runtime/api/JSBundler.rs

Comment thread repro/rootB-verify/verify.mjs
Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts
Comment on lines 404 to +413
// lets make sure that we always call deinit from main thread
// `from_callback` heap-allocates a fresh `ConcurrentTaskItem`; the queue
// takes ownership of it.
Self::enqueue_concurrent(
self_.javascript_vm,
ConcurrentTask::from_callback(this, FetchTasklet::deinit_callback),
);
let node = ConcurrentTask::from_callback(this, FetchTasklet::deinit_callback);
if !Self::enqueue_concurrent(self_.context_id, node) {
// SAFETY: ownership not transferred; `node` is a `from_callback` allocation.
unsafe { ConcurrentTask::destroy_from_callback(node) };
// SAFETY: last ref; see the `!is_alive()` branch above.
unsafe { FetchTasklet::dealloc_for_shutdown(this) };
}

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 worker-terminate abandon path here (both the !is_alive() branch at line 400 and the new !enqueue_concurrent() fallback at line 412) still routes to dealloc_for_shutdown, which parks the tasklet in the process-global SHUTDOWN_RECLAIMS list — drained only by the main VM's global_exit() — so at process exit deinit() releases the tasklet's Strong/Weak/JSPromiseStrong handles into the long-freed worker HandleSet, and the boxes accumulate unboundedly across worker create/terminate cycles until then. Pre-existing (the old is_shutting_down() gate reached the same sink, and its flag read was itself a UAF), so not a regression — but it violates this PR's own abandon-path contract ("must not touch Strong/Weak/JSPromiseStrong"). For worker contexts, leak the box outright (matching WorkTask/ConcurrentPromiseTask/AnyTaskJob/PasswordJob) and reserve dealloc_for_shutdown for the main-context process-exit case its own doc-comment describes.

Extended reasoning...

What the bug is

FetchTasklet::deref_from_thread's abandon path — both the !self_.context_id.is_alive() branch (line 394→400) and the new !Self::enqueue_concurrent(...) race-window fallback (line 408→412) — calls dealloc_for_shutdown(this). That function (lines 527–532) parks the tasklet via http::defer_shutdown_reclaim(this, FetchTasklet::deinit_erased) into the process-global SHUTDOWN_RECLAIMS list (HTTPThread.rs:1404, 1411–1415). SHUTDOWN_RECLAIMS is drained only inside shutdown_for_exit() (HTTPThread.rs:1469–1473), whose sole runtime caller is VirtualMachine::global_exit() on the main VM at process exit. WebWorker::shutdown never calls it — and cannot, since the HTTP thread is process-global and other VMs still use it.

The mechanism's own doc-comment (lines 519–523: "the drain runs from global_exit() after the HTTP thread has parked but before destructOnExit, so deinit() there can release every handle on the right thread") is written entirely for the main-VM process-exit case — it assumes the tasklet's JSC handles belong to the same VM whose global_exit() is running and whose HandleSet is still live. That invariant does not hold for a tasklet whose JSC heap belongs to a worker that terminated long ago.

The code path

The abandon closure in the HTTP callback (line ~2477) reaches this via 2× deref_from_thread(task) when is_done. The last of those hits ref_count==0, sees !context_id.is_alive() (or races into !enqueue_concurrent()), and calls dealloc_for_shutdown. Same path from on_write_request_data_drain's abandon → deref_from_thread.

Why existing code doesn't prevent it

dealloc_for_shutdown correctly avoids touching JSC handles immediately on the HTTP thread. But it defers a full deinit()clear_data() calls self.response.clear() (jsc::Weak, line 473), readable_stream_ref.deinit(), abort_reason.deinit() / check_server_identity.deinit() (StrongOptional), clear_abort_signal(), and then drop(boxed) runs Drop for promise: JSPromiseStrong. Every one of those releases a slot in the worker's JSC HandleSet/WeakSet, freed by teardownJSCVM when that worker terminated. This is exactly what the PR's own docs/ROOT-B-SHUTDOWN-FENCE.md §Abandon-path forbids: "must not touch Strong/Weak/JSPromiseStrong … the HandleSet is freed" — just deferred to process exit rather than avoided.

Impact

  1. Deferred UAF at process exit: main JS thread runs deinit() on a box whose JSC handles point into a freed worker HandleSet. Low practical severity (process is dying), but ASAN-visible.
  2. Unbounded accumulation: each terminated worker with an in-flight fetch whose HTTP-thread callback lands post-markTerminating() parks one ~1KB+ box in SHUTDOWN_RECLAIMS for the rest of the process lifetime — accumulates across repeated worker create/terminate in a long-running server, contrary to the design doc's "bounded: one per terminated worker per in-flight op" (which assumes the leak dies with the worker, not accumulates process-wide).

Step-by-step proof

  1. Worker starts fetch("http://127.0.0.1:.../"); FetchTasklet::get() heap-allocates the tasklet with context_id = <worker id>, abort_reason/check_server_identity/response/promise all handles into the worker's JSC heap; ref_count = 2; HTTP thread has the request in flight.
  2. Parent calls worker.terminate(). Worker JS thread: step 2b markTerminating() (sets isTerminating under the contexts-map lock) → 2d drain → step 3 teardownJSCVM (frees the worker's HandleSet/WeakSet) → step 5 dealloc(vm_ptr).
  3. HTTP thread receives the final result and enters callback. task_ref.context_id.is_alive() returns false (isTerminating set) → abandon(task_ref) → 2× deref_from_thread(task). The second call hits ref_count==0 → !self_.context_id.is_alive()dealloc_for_shutdown(this)SHUTDOWN_RECLAIMS.push({this, deinit_erased}).
  4. Process runs on. Server repeats steps 1–3 across many worker create/terminate cycles; each parks another tasklet in SHUTDOWN_RECLAIMS.
  5. Eventually the main VM calls global_exit()bun_http::shutdown_for_exit()for r in SHUTDOWN_RECLAIMS { (r.drop_fn)(r.ctx) }. On the main JS thread, FetchTasklet::deinit_eraseddeinit(this)clear_data()abort_reason.deinit() etc. reach into the worker HandleSet freed at step 2/3 — heap-use-after-free.

Pre-existing?

Yes. Before this PR the guard was self_.javascript_vm.is_shutting_down(). During worker-shutdown steps 2a–4 that flag read true, so worker tasklets reached dealloc_for_shutdown(this) then too (identical call at the old line 400); after step 5 the flag read was itself a UAF into the freed VM box. So the leak-into-SHUTDOWN_RECLAIMS + deferred-process-exit-UAF was already there. This PR fixes the immediate UAF-read and is a strict improvement, but keeps routing to the same broken sink and adds a second deterministic entry (line 412). Per REVIEW.md "Fix the whole class in the same PR", flagging because the PR touches these exact lines and its own abandon-path contract explicitly forbids what dealloc_for_shutdown does for worker contexts.

Fix

For worker contexts, do what every other converted site does: leak the box outright (matching WorkTask/ConcurrentPromiseTask/AnyTaskJob), or free the pure-Rust fields (scheduled_response_buffer, response_buffer, request_headers, http, url_proxy_buffer) and mem::forget the JSC-handle fields — mirroring PasswordJob. Reserve dealloc_for_shutdown for the main-VM process-exit case it was designed for, e.g. gate on self_.context_id == <main context id> or on self_.javascript_vm being the main VM (that pointer read is safe here since FetchTasklet is a separate heap box).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this is the wrong sink for worker tasklets, but it is the pre-existing sink this branch has always taken (the old is_shutting_down() gate reached the same dealloc_for_shutdown call, and that flag read was itself a UAF). This PR changed only the gate, not the sink. The proper fix is to make dealloc_for_shutdown (or a worker-specific sibling) free the Rust payload and mem::forget the JSC handles instead of parking for a global_exit() that belongs to a different VM; that touches the FetchTasklet deinit machinery rather than the enqueue fence, so I would rather land it separately. Leaving unresolved for the maintainer to weigh in.

Comment thread src/runtime/webcore/s3/download_stream.rs Outdated
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI builds 81365 and 81483 both failed with every build-cpp/build-bun job expiring before an agent picked it up (no compile or test ran). Main's build 81444 shows the same pattern (52 expired, 106 waiting_failed), so this is fleet-wide queue congestion rather than anything in the diff.

The diff builds and passes its regression test locally under debug+ASAN (20/20 clean teardowns where stock canary SIGSEGVs at teardown ~9). Ready for a re-run once the queue clears.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by the Worker rewrite in #37075, which landed on main and closed #32073. It solves the same problem this PR generalised (off-thread completions posting back into a VM that terminate() may have freed) at the same chokepoint: every cross-thread completion (thread pool, HTTP thread, bundle thread, napi, JSC helper threads) now posts through a per-VM handle that teardown closes, and a late completion is refused and released by its producer.

Verified on current main (165dc9f, debug+ASAN build): this PR's test, "terminate() while cross-thread WorkPool completions are in flight does not UAF on enqueue", passes 4 out of 4 runs, and the standalone harness from repro/rootB-verify reports 30 out of 30 teardowns clean. Closing.

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

Worker lifetime: carry a generation token with cross-thread VM handles (follow-up to #32071)

2 participants