Skip to content

Fix use-after-free when worker.terminate() races in-flight fetch/work-pool completions - #34154

Open
Jarred-Sumner wants to merge 2 commits into
mainfrom
claude/vmhandle-worker-terminate-uaf
Open

Fix use-after-free when worker.terminate() races in-flight fetch/work-pool completions#34154
Jarred-Sumner wants to merge 2 commits into
mainfrom
claude/vmhandle-worker-terminate-uaf

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

What

worker.terminate() freed the worker's VirtualMachine while work-pool and HTTP-thread completions still held raw pointers into it. The late enqueue_task_concurrent into the freed event loop corrupted whatever reused the allocation. Three observable faces depending on timing:

heap-use-after-free  READ of size 8  thread (Bun Pool)
  #0 event_loop                                  src/jsc/VirtualMachine.rs:716
  #1 AsyncFSTask<CopyFile>::work_pool_callback   src/runtime/node/node_fs.rs:1318
freed by thread (Worker):  WebWorker::shutdown   src/jsc/web_worker.rs:1390

plus Option::unwrap() panic in AsyncFSTask::run_from_js_thread (promise Strong read after the slot emptied), and JSC StructureID::decode / Heap m_collectionScope asserts when the freed slab is reused.

Design

  1. Pin. VirtualMachine owns an Arc<ShutdownGate>; vm.pin() returns an RAII GateGuest. Every async producer takes a pin at creation on the JS thread and drops it on the completing thread after the completion enqueue. Covered producers: node:fs async ops (including recursive cp/readdir), fetch, S3 (simple/streaming/multipart), dns, zlib/brotli/zstd writes, Bun.password, the generic AnyTaskJob (pbkdf2, scrypt, Bun.secrets, …), napi async work, Archive, Bun.build, stat-watcher (periodic + initial), runtime transpiler.
  2. Terminate = abort → wait → drain → tear down. A per-VM TerminateAbortRegistry (fetch + every S3 request type) is walked first so pins drop promptly; close_and_wait() blocks until all pins drop; queued completions are reclaimed per-tag with JSC alive; then JSC teardown and box free. Main-VM exit closes without waiting (its box is never freed).
  3. AsyncFSTask::run_from_js_thread bails early when the promise Strong is empty or the VM is stopping, so the drain-race path cleans up instead of panicking the process.

Known residuals (pre-existing, same race class, not addressed here)

  • spawn waiter thread, Bun.$ shell tasks, and napi ThreadSafeFunction still enqueue cross-thread without a pin.
  • Plugin builds run unpinned (a pinned build round-tripping through the terminating JS thread would deadlock).
  • Drain-time reclaim for AsyncCpTask / TranspilerJob / RequestContext S3 ctx is not wired; these leak a bounded box per in-flight op at terminate.

Tests

test/js/web/workers/worker-terminate-lifetime.test.ts: fetch + 6 work-pool producer families (password, zlib, fs stat, fs readFile/writeFile/copyFile, dns, crypto), each terminating workers mid-op. On an unfixed debug+ASAN build: Bun.password, node:crypto, and node:dns abort with the heap-use-after-free above; with the fix, 11/11 pass.

Raw repro (release build, no ASAN):

const { Worker } = require("node:worker_threads");
const fs = require("node:fs"), os = require("node:os"), path = require("node:path");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "wt-"));
const big = path.join(tmp, "big.bin"); fs.writeFileSync(big, Buffer.alloc(48 << 20, 0x5a));
const src = `const { parentPort, workerData } = require("node:worker_threads");
const fsp = require("node:fs/promises");
const lanes = (n, f) => { for (let i = 0; i < n; i++) (async () => { for (;;) { try { await f(i); } catch {} } })(); };
lanes(4, () => fsp.readFile(workerData.big));
lanes(2, (i) => fsp.writeFile(workerData.tmp + "/w" + i, Buffer.alloc(8 << 20, 1)));
lanes(2, (i) => fsp.copyFile(workerData.big, workerData.tmp + "/c" + i));
parentPort.postMessage("up");`;
for (let r = 0; r < 12; r++) {
  const w = new Worker(src, { eval: true, workerData: { big, tmp } });
  await new Promise((res) => w.once("message", res));
  await Bun.sleep(60 + (r * 41) % 220);
  await w.terminate();
}

Segfaults at address 0x8 after round 1 on a release build; completes all 12 rounds with the fix.

rust:check-all: 10/10 target combos OK.

Rebase notes (e9c14e1)

Rebased onto 47597ab as a single commit. Review findings from the previous round addressed: InitialStatTask now pinned; FetchTasklet::release_at_shutdown pin-drop ordering fixed (pin local, drop last); any_task_job uses script_execution_status() (the prior has_termination_request accessor was removed in #35002); stale doc comments in event_loop.rs / node_fs_stat_watcher.rs updated; duplicate #[allow] in simple_request.rs removed. generate_from_javascript / listen_callback / SourceType / bun_threading::Once reintroductions dropped (dead on current main). Added a leaksan.supp entry for the pre-existing per-worker node_fs_binding::Binding leak at terminate (fails on main independent of this diff; tracked separately).

Supersedes #32071.

@robobun

robobun commented Jul 14, 2026

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

@autofix-ci[bot], your commit c3e5318 has 3 failures in Build #77970 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34154

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

bun-34154 --bun

@9Morello

Copy link
Copy Markdown

get some rest, Jarred, it's 3 AM :)

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

This change adds gated cross-thread VM handles, migrates asynchronous completion paths from raw VM or event-loop references, updates fetch and S3 shutdown handling, maps cancelled DNS completions to rejected promises, and adds worker termination regression tests.

Cross-thread VM shutdown safety

Layer / File(s) Summary
Shutdown gate and VMHandle primitives
src/threading/ShutdownGate.rs, src/threading/lib.rs, src/jsc/vm_handle.rs, src/jsc/lib.rs
Adds counted shutdown gating, cross-thread VM access and enqueue helpers, and public exports.
VM lifecycle gate integration
src/jsc/VirtualMachine.rs, src/jsc/web_worker.rs
Initializes and closes the gate during VM teardown and worker shutdown.
Async task completion migration
src/jsc/*Task.rs, src/runtime/api/Archive.rs, src/runtime/crypto/PasswordObject.rs, src/runtime/napi/napi_body.rs, src/runtime/node/...
Replaces stored event-loop or raw VM references with VMHandle and routes completion tasks through handle-based enqueue operations.
Fetch and S3 scheduling migration
src/runtime/webcore/fetch/FetchTasklet.rs, src/runtime/webcore/s3/*
Uses VM handles for fetch and S3 callbacks, with cleanup paths when the VM is shutting down or enqueueing fails.
DNS cancellation handling
src/runtime/dns_jsc/dns.rs
Rejects promises for empty completion results with cancellation errors and propagates conversion failures as empty results.
Worker lifetime validation
test/js/web/workers/worker-terminate-lifetime.test.ts
Adds termination races covering streaming fetches and asynchronous work-pool jobs.
Remaining worker drain assumption
src/jsc/RuntimeTranspilerStore.rs
Documents that worker VM termination can require draining or joining in-flight transpiler work.

Possibly related issues

  • oven-sh/bun#33911 — Addresses the worker termination race involving in-flight fetch callbacks and VM teardown.

Suggested reviewers: alii

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main fix: worker.terminate() races causing use-after-free in in-flight fetch/work-pool completions.
Description check ✅ Passed The description is mostly complete and includes the problem, design, tests, and verification details, though the template headings aren't exact.

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

@github-actions

Copy link
Copy Markdown
Contributor

Found 6 issues this PR may fix:

  1. [CRASH] Worker termination races in-flight fetch(), corrupting the event loop's concurrent task queue (two crash signatures) #33911 - Exact crash signatures (segfault at 0x0 in UnboundedQueue::push_batch, FetchTasklet callback UAF) match this PR's root cause
  2. Worker lifetime: carry a generation token with cross-thread VM handles (follow-up to #32071) #32073 - This PR implements the VMHandle design proposed in this issue for carrying generation tokens with cross-thread VM handles
  3. 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 with dispatches in flight — same cross-thread freed-VM access pattern
  4. Worker create+terminate cycle aborts process after ~100k–900k iterations on macOS arm64 #30421 - Worker create+terminate cycle abort on macOS arm64 — tight loop exercises the exact race window this PR closes
  5. Segmentation fault (SIGILL) in long-running parallel async processes with high-frequency database operations #22998 - Crash at address 0x0 in HTTPThread.processEvents with 20 concurrent workers doing high-frequency HTTP — same FetchTasklet callback path
  6. Worker & worker_threads stability tracking issue #15964 - PR directly addresses TODO item 2: "Make all usages of *JSC.EventLoop use a weak pointer" — VMHandle is exactly this mechanism

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

Fixes #33911
Fixes #32073
Fixes #31880
Fixes #30421
Fixes #22998
Fixes #15964

🤖 Generated with Claude Code

Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated
Comment thread src/runtime/webcore/s3/client.rs Outdated
Comment thread src/threading/ShutdownGate.rs

@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: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/jsc/RuntimeTranspilerStore.rs (1)

518-529: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Do not leave worker-VM teardown relying on this gate. The FIXME shows terminate() can free the VM and hive slot mid-flight, so this path still has a use-after-free risk. Add the drain/join protocol here or prove worker VMs can never queue TranspilerJobs.

🤖 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/jsc/RuntimeTranspilerStore.rs` around lines 518 - 529, Fix the worker-VM
teardown race in the queueing path around RuntimeTranspilerStore and event_loop:
add the required drain/join synchronization so terminate() cannot free the VM,
hive slot, or transpiler store while the job is being queued and scheduled.
Alternatively, establish and enforce a guarantee that worker VMs never queue
TranspilerJob instances; remove the FIXME only once the use-after-free risk is
eliminated.
src/runtime/node/node_fs.rs (1)

2176-2210: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

AsyncReaddirRecursiveTask should carry a VMHandle finish_concurrently() still does an unsafe bun_vm_concurrently() dereference from work-pool threads, so worker shutdown can race this path and free the VM/event loop before the last recursive-readdir subtask finishes. Add vm: VMHandle here and enqueue the completion through vm.enqueue_task_concurrent(...) like AsyncFSTask.

🤖 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/runtime/node/node_fs.rs` around lines 2176 - 2210, Add a VMHandle field
to AsyncReaddirRecursiveTask, initialize it when creating the task, and update
finish_concurrently() to enqueue completion via vm.enqueue_task_concurrent(...)
instead of directly dereferencing bun_vm_concurrently(). Match AsyncFSTask’s VM
lifetime and completion-enqueue pattern so the VM remains alive until all
recursive-readdir subtasks finish.
🤖 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 `@src/jsc/VirtualMachine.rs`:
- Around line 995-1017: In cross_thread_handle, replace the handle_gate Arc’s
method-style clone with the explicit Arc::clone form while preserving the
existing expect and VMHandle construction.

In `@src/jsc/vm_handle.rs`:
- Around line 27-28: Add a separate adjacent SAFETY comment immediately before
the unsafe Sync implementation in the VMHandle declarations, while retaining the
existing safety comment for Send. Ensure both unsafe impls have their own
explicit safety justification.

In `@src/runtime/webcore/fetch/FetchTasklet.rs`:
- Around line 402-418: Ensure worker shutdown drains FetchTasklets parked by
defer_shutdown_reclaim(), not only the process-exit path in
HTTPThread::shutdown_for_exit(). Update the
WebWorker::shutdown()/VirtualMachine::destroy() shutdown flow to invoke the
existing drain at the appropriate point, while preserving global_exit() behavior
and avoiding duplicate or unsafe reclamation.

In `@src/threading/ShutdownGate.rs`:
- Around line 1-65: Add a multi-threaded stress test for the ShutdownGate state
machine, repeatedly racing many concurrent enter/leave operations against
close_and_wait. Verify successful entrants always leave, entrants after closure
are rejected, close_and_wait completes only after all guests exit, and the gate
remains closed afterward.

In `@test/js/web/workers/worker-terminate-lifetime.test.ts`:
- Around line 134-137: Update both regression test assertion blocks in
worker-terminate-lifetime.test.ts to assert stderr first and unconditionally
before stdout or exitCode. Preserve the existing stdout and exitCode assertions,
and ensure any ASAN/UAF output on stderr fails the tests even when the process
prints "done\n" and exits successfully.

---

Outside diff comments:
In `@src/jsc/RuntimeTranspilerStore.rs`:
- Around line 518-529: Fix the worker-VM teardown race in the queueing path
around RuntimeTranspilerStore and event_loop: add the required drain/join
synchronization so terminate() cannot free the VM, hive slot, or transpiler
store while the job is being queued and scheduled. Alternatively, establish and
enforce a guarantee that worker VMs never queue TranspilerJob instances; remove
the FIXME only once the use-after-free risk is eliminated.

In `@src/runtime/node/node_fs.rs`:
- Around line 2176-2210: Add a VMHandle field to AsyncReaddirRecursiveTask,
initialize it when creating the task, and update finish_concurrently() to
enqueue completion via vm.enqueue_task_concurrent(...) instead of directly
dereferencing bun_vm_concurrently(). Match AsyncFSTask’s VM lifetime and
completion-enqueue pattern so the VM remains alive until all recursive-readdir
subtasks finish.
🪄 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: 326c18b8-28c9-4226-bcab-a8faee7ff33f

📥 Commits

Reviewing files that changed from the base of the PR and between cc0c1e8 and ea006b6.

📒 Files selected for processing (23)
  • src/jsc/ConcurrentPromiseTask.rs
  • src/jsc/RuntimeTranspilerStore.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/WorkTask.rs
  • src/jsc/lib.rs
  • src/jsc/vm_handle.rs
  • src/jsc/web_worker.rs
  • src/runtime/api/Archive.rs
  • src/runtime/crypto/PasswordObject.rs
  • src/runtime/napi/napi_body.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/webcore/fetch/FetchTasklet.rs
  • src/runtime/webcore/s3/client.rs
  • src/runtime/webcore/s3/download_stream.rs
  • src/runtime/webcore/s3/multipart.rs
  • src/runtime/webcore/s3/simple_request.rs
  • src/threading/ShutdownGate.rs
  • src/threading/lib.rs
  • test/js/web/workers/worker-terminate-lifetime.test.ts
💤 Files with no reviewable changes (1)
  • src/runtime/webcore/s3/multipart.rs

Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/vm_handle.rs Outdated
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated
Comment thread src/threading/ShutdownGate.rs
Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/vmhandle-worker-terminate-uaf branch from ea006b6 to ef9135e Compare July 14, 2026 10:58

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/RuntimeTranspilerStore.rs (1)

516-530: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Confirmed: TranspilerJob is not yet migrated to VMHandle and remains UAF-exposed on worker terminate().

self.vm is a raw *mut VirtualMachine dereferenced unconditionally throughout run()/dispatch_to_main_thread() (not just here) — if a worker's terminate() frees the VM while this job is mid-flight on a WorkPool thread, this is the same use-after-free class the rest of this PR fixes. The FIXME correctly notes a VMHandle gate alone isn't sufficient here (the job's hive slot itself lives in vm.transpiler_store, so the fix needs a drain/join at worker shutdown, not just a pinned pointer).

Given this matches the PR's own "documented follow-up" scope, would you like me to open a tracking issue for migrating TranspilerJob (drain/join transpiler_store.queue before the VM is freed), or help draft an initial approach?

🤖 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/jsc/RuntimeTranspilerStore.rs` around lines 516 - 530, Track the
documented follow-up for TranspilerJob rather than treating the VMHandle gate as
sufficient: migrate the raw self.vm usage across run() and
dispatch_to_main_thread() to a safe VMHandle-based lifetime model, and add
worker-shutdown drain/join coordination for transpiler_store.queue before
terminate() frees the VM. Preserve the existing event-loop dispatch behavior
while ensuring queued jobs cannot access a freed VM or hive slot.
🤖 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 `@src/jsc/vm_handle.rs`:
- Around line 42-51: Update VMHandle::with so the gate count is released on
every exit path, including when the caller-supplied closure f panics. After a
successful gate.enter(), introduce a scope-bound drop guard whose cleanup calls
self.gate.leave(), then invoke f without a separate unconditional leave;
preserve the existing None and Some(result) behavior.

In `@src/runtime/napi/napi_body.rs`:
- Around line 125-129: Remove the function-local has_pending_exception checks
from napi_get_prototype and the additionally affected path, relying on the
centralized NAPI_PREAMBLE pending-exception gate instead. Verify those entry
points pass through NAPI_PREAMBLE; only retain a local check if that centralized
gate does not cover the path and the sanctioned interim behavior requires it.

---

Outside diff comments:
In `@src/jsc/RuntimeTranspilerStore.rs`:
- Around line 516-530: Track the documented follow-up for TranspilerJob rather
than treating the VMHandle gate as sufficient: migrate the raw self.vm usage
across run() and dispatch_to_main_thread() to a safe VMHandle-based lifetime
model, and add worker-shutdown drain/join coordination for
transpiler_store.queue before terminate() frees the VM. Preserve the existing
event-loop dispatch behavior while ensuring queued jobs cannot access a freed VM
or hive slot.
🪄 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: 66cc8c2a-7fdf-46cc-9e77-238fef048e01

📥 Commits

Reviewing files that changed from the base of the PR and between ea006b6 and ef9135e.

📒 Files selected for processing (23)
  • src/jsc/ConcurrentPromiseTask.rs
  • src/jsc/RuntimeTranspilerStore.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/WorkTask.rs
  • src/jsc/lib.rs
  • src/jsc/vm_handle.rs
  • src/jsc/web_worker.rs
  • src/runtime/api/Archive.rs
  • src/runtime/crypto/PasswordObject.rs
  • src/runtime/napi/napi_body.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/webcore/fetch/FetchTasklet.rs
  • src/runtime/webcore/s3/client.rs
  • src/runtime/webcore/s3/download_stream.rs
  • src/runtime/webcore/s3/multipart.rs
  • src/runtime/webcore/s3/simple_request.rs
  • src/threading/ShutdownGate.rs
  • src/threading/lib.rs
  • test/js/web/workers/worker-terminate-lifetime.test.ts
💤 Files with no reviewable changes (1)
  • src/runtime/webcore/s3/multipart.rs

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

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/RuntimeTranspilerStore.rs (1)

516-530: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Confirmed: TranspilerJob is not yet migrated to VMHandle and remains UAF-exposed on worker terminate().

self.vm is a raw *mut VirtualMachine dereferenced unconditionally throughout run()/dispatch_to_main_thread() (not just here) — if a worker's terminate() frees the VM while this job is mid-flight on a WorkPool thread, this is the same use-after-free class the rest of this PR fixes. The FIXME correctly notes a VMHandle gate alone isn't sufficient here (the job's hive slot itself lives in vm.transpiler_store, so the fix needs a drain/join at worker shutdown, not just a pinned pointer).

Given this matches the PR's own "documented follow-up" scope, would you like me to open a tracking issue for migrating TranspilerJob (drain/join transpiler_store.queue before the VM is freed), or help draft an initial approach?

🤖 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/jsc/RuntimeTranspilerStore.rs` around lines 516 - 530, Track the
documented follow-up for TranspilerJob rather than treating the VMHandle gate as
sufficient: migrate the raw self.vm usage across run() and
dispatch_to_main_thread() to a safe VMHandle-based lifetime model, and add
worker-shutdown drain/join coordination for transpiler_store.queue before
terminate() frees the VM. Preserve the existing event-loop dispatch behavior
while ensuring queued jobs cannot access a freed VM or hive slot.
🤖 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 `@src/jsc/vm_handle.rs`:
- Around line 42-51: Update VMHandle::with so the gate count is released on
every exit path, including when the caller-supplied closure f panics. After a
successful gate.enter(), introduce a scope-bound drop guard whose cleanup calls
self.gate.leave(), then invoke f without a separate unconditional leave;
preserve the existing None and Some(result) behavior.

In `@src/runtime/napi/napi_body.rs`:
- Around line 125-129: Remove the function-local has_pending_exception checks
from napi_get_prototype and the additionally affected path, relying on the
centralized NAPI_PREAMBLE pending-exception gate instead. Verify those entry
points pass through NAPI_PREAMBLE; only retain a local check if that centralized
gate does not cover the path and the sanctioned interim behavior requires it.

---

Outside diff comments:
In `@src/jsc/RuntimeTranspilerStore.rs`:
- Around line 516-530: Track the documented follow-up for TranspilerJob rather
than treating the VMHandle gate as sufficient: migrate the raw self.vm usage
across run() and dispatch_to_main_thread() to a safe VMHandle-based lifetime
model, and add worker-shutdown drain/join coordination for
transpiler_store.queue before terminate() frees the VM. Preserve the existing
event-loop dispatch behavior while ensuring queued jobs cannot access a freed VM
or hive slot.
🪄 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: 66cc8c2a-7fdf-46cc-9e77-238fef048e01

📥 Commits

Reviewing files that changed from the base of the PR and between ea006b6 and ef9135e.

📒 Files selected for processing (23)
  • src/jsc/ConcurrentPromiseTask.rs
  • src/jsc/RuntimeTranspilerStore.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/WorkTask.rs
  • src/jsc/lib.rs
  • src/jsc/vm_handle.rs
  • src/jsc/web_worker.rs
  • src/runtime/api/Archive.rs
  • src/runtime/crypto/PasswordObject.rs
  • src/runtime/napi/napi_body.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/webcore/fetch/FetchTasklet.rs
  • src/runtime/webcore/s3/client.rs
  • src/runtime/webcore/s3/download_stream.rs
  • src/runtime/webcore/s3/multipart.rs
  • src/runtime/webcore/s3/simple_request.rs
  • src/threading/ShutdownGate.rs
  • src/threading/lib.rs
  • test/js/web/workers/worker-terminate-lifetime.test.ts
💤 Files with no reviewable changes (1)
  • src/runtime/webcore/s3/multipart.rs
🛑 Comments failed to post (2)
src/jsc/vm_handle.rs (1)

42-51: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

VMHandle::with() leaks the gate's guest count on panic, risking a shutdown hang.

self.gate.leave() only runs if f returns normally. If f panics, the guest count is never decremented, so a subsequent close_and_wait() (worker termination) blocks forever waiting for a count that will never reach zero. As per path instructions: "Reference counts must balance on every terminal path: success, error, cancellation, and finalize." Since f is a caller-supplied closure (not proven panic-free), the count should be balanced with a drop guard instead of a plain post-call decrement.

🔒️ Proposed fix using a drop guard
     pub fn with<R>(&self, f: impl FnOnce(&VirtualMachine) -> R) -> Option<R> {
         if !self.gate.enter() {
             return None;
         }
+        // Ensures `leave()` runs even if `f` panics, keeping the guest count
+        // balanced on every terminal path (including panic/unwind).
+        struct LeaveGuard<'a>(&'a ShutdownGate);
+        impl Drop for LeaveGuard<'_> {
+            fn drop(&mut self) {
+                self.0.leave();
+            }
+        }
+        let _guard = LeaveGuard(&self.gate);
         // SAFETY: gate held open — `close_and_wait()` in worker shutdown
         // blocks until we `leave()`, so the allocation outlives this call.
         let result = f(unsafe { self.vm.as_ref() });
-        self.gate.leave();
         Some(result)
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    pub fn with<R>(&self, f: impl FnOnce(&VirtualMachine) -> R) -> Option<R> {
        if !self.gate.enter() {
            return None;
        }
        struct LeaveGuard<'a>(&'a ShutdownGate);
        impl<'a> Drop for LeaveGuard<'a> {
            fn drop(&mut self) {
                self.0.leave();
            }
        }
        let _guard = LeaveGuard(&self.gate);
        // SAFETY: gate held open — `close_and_wait()` in worker shutdown
        // blocks until we `leave()`, so the allocation outlives this call.
        let result = f(unsafe { self.vm.as_ref() });
        Some(result)
    }
🤖 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/jsc/vm_handle.rs` around lines 42 - 51, Update VMHandle::with so the gate
count is released on every exit path, including when the caller-supplied closure
f panics. After a successful gate.enter(), introduce a scope-bound drop guard
whose cleanup calls self.gate.leave(), then invoke f without a separate
unconditional leave; preserve the existing None and Some(result) behavior.

Source: Path instructions

src/runtime/napi/napi_body.rs (1)

125-129: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Ad hoc pending-exception check re-introduced in napi_get_prototype.

A prior learning on this exact function recommended relying on the centralized NAPI_PREAMBLE gate rather than adding function-local has_pending_exception() checks. This change adds exactly that pattern back. If the centralized gate now covers this path, the local check is redundant; if not, please confirm this is the sanctioned interim approach rather than a re-introduced anti-pattern.

Based on learnings, "avoid adding ad-hoc pending-exception checks inside individual N-API entry-point functions... Instead, rely on the centralized NAPI_PREAMBLE pending-exception gate."

Also applies to: 882-896

🤖 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/runtime/napi/napi_body.rs` around lines 125 - 129, Remove the
function-local has_pending_exception checks from napi_get_prototype and the
additionally affected path, relying on the centralized NAPI_PREAMBLE
pending-exception gate instead. Verify those entry points pass through
NAPI_PREAMBLE; only retain a local check if that centralized gate does not cover
the path and the sanctioned interim behavior requires it.

Source: Learnings

Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated
Comment thread src/runtime/node/node_fs.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: 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 `@src/runtime/dns_jsc/dns.rs`:
- Around line 2116-2129: Update the cancelled early-return branch in
DNSLookup::on_complete to call resolver.request_completed() before
Self::destroy(this), matching the non-empty completion path and sibling
cancellation handlers while preserving the existing rejection 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: 1d3cbd2f-b7c8-4efc-b4d1-a183961d8c55

📥 Commits

Reviewing files that changed from the base of the PR and between ef9135e and 2136034.

📒 Files selected for processing (1)
  • src/runtime/dns_jsc/dns.rs

Comment thread src/runtime/dns_jsc/dns.rs

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/jsc/web_worker.rs:1289-1290 — A worker FetchTasklet whose intrusive concurrent_task was already enqueued before close_cross_thread_gate() and is then drained by release_queued_tasks_for_shutdown() never has its transfer aborted: __bun_release_task_at_shutdown's FetchTasklet arm (dispatch.rs:1169-1173) only deref()s and never resets has_schedule_callback, so subsequent HTTP-thread callbacks fail the CAS at line 2447, take the early return at 2454-2459, and never reach the || enqueued.is_none()schedule_shutdown abort this PR adds at line 2481. Body bytes accumulate in scheduled_response_buffer until the server closes. The one-line fix — also has_schedule_callback.store(false, Relaxed) in that arm — lets the next callback win the CAS, get None from enqueue_concurrent, and hit the abort this PR already adds. (The dispatch.rs comment "HTTP daemon is already parked" is also now false for the worker-shutdown caller and should be updated.)

    Extended reasoning...

    What the bug is

    This is distinct from the two existing dealloc_for_shutdown findings on this PR (which cover enqueue_concurrent returning None or Some(false) at the moment of enqueue). This covers the third case: the tasklet's intrusive concurrent_task was successfully enqueued (Some(true)) before close_cross_thread_gate() ran, and is then drained by release_queued_tasks_for_shutdown() at web_worker.rs:1290.

    __bun_release_task_at_shutdown's FetchTasklet arm at dispatch.rs:1169-1173 does exactly one thing:

    task_tag::FetchTasklet => {
        // SAFETY: `task.ptr` is the live heap `FetchTasklet`; HTTP daemon is
        // already parked so we hold the sole reference.
        FetchTasklet::deref(task.ptr.cast::<FetchTasklet>());
        true
    }

    It does not reset has_schedule_callback and does not abort the transfer. Its SAFETY comment ("HTTP daemon is already parked") is true for the main-VM global_exit() path (which calls shutdown_for_exit() before the drain) but false for the worker-shutdown caller this PR now sequences at web_worker.rs:1289-1290 — worker shutdown does not park the shared HTTP daemon.

    The normal dispatch path — on_progress_update at FetchTasklet.rs:815 — is the only writer of has_schedule_callback = false. When the shutdown-drain arm runs instead, that flag stays stuck at true.

    Step-by-step proof

    1. Worker starts fetch() on a streaming response. HTTP thread fires a mid-stream callback (is_done = false): appends body bytes at 2431-2435, wins the has_schedule_callback CAS false→true at 2447, calls enqueue_concurrentgate.enter() succeeds → the intrusive concurrent_task is pushed → Some(true). Refcount stays 2.
    2. worker.terminate()WebWorker::shutdown(): vm.close_cross_thread_gate() at web_worker.rs:1289 (waits out any in-flight guest, so the enqueue in step 1 is complete and the task is in the queue), then release_queued_tasks_for_shutdown() at :1290.
    3. drop_concurrent_cpp_tasks() forwards the FetchTasklet entry from concurrent_tasks into self.tasks; __bun_release_task_at_shutdown runs the FetchTasklet arm → FetchTasklet::deref() (refcount 2→1). has_schedule_callback is still true.
    4. Worker VM freed (step 5 of shutdown()).
    5. HTTP thread receives the next chunk. callback() locks the tasklet's mutex, appends the chunk to scheduled_response_buffer at 2431-2435, then the CAS at 2447 fails with Err(true) (flag still true from step 1, never reset). It takes the early return at 2453-2459 — never reaching enqueue_concurrent at 2465, so it never observes the closed gate (None) and never reaches the schedule_shutdown abort at 2481-2484 that this PR added specifically for the dead-worker case.
    6. Repeat for every subsequent chunk. The transfer runs until the server closes; body bytes accumulate in the leaked tasklet's scheduled_response_buffer. For the PR's own new fetch test (server drips 1 KB/ms forever), each terminated worker's fetches whose task was queued at the moment of gate-close keep streaming into leaked buffers until process.exit(0).

    Why existing code doesn't prevent it

    • The PR's new abort at FetchTasklet.rs:2481 (|| enqueued.is_none()schedule_shutdown) is only reachable after winning the CAS at 2447; a stuck-true flag makes it unreachable.
    • close_cross_thread_gate() only refuses new guests; it does not touch tasks already in the queue.
    • The CAS-fail early-return path (lines 2340-2459) touches only tasklet-owned fields (mutex, http, result, response_buffer, scheduled_response_buffer, has_schedule_callback) — never javascript_vm — so the closed gate is never observed on this path.

    Impact and severity

    This is nit severity because the mid-stream accumulation itself is pre-existing — the CAS-fail early-return path did not touch the VM before this PR either, so a stuck-true flag after a shutdown-drain already produced this exact behavior. Only the eventual is_done=truederef_from_threadis_shutting_down() read UAF'd before, and this PR fixes that. So the PR is a strict improvement over baseline (leak instead of crash) for this ordering.

    However, it's worth mentioning because: (a) the PR description explicitly claims "Fetches … in flight are aborted on their next HTTP-thread event … once the gate is closed", and this ordering defeats that claim; (b) this PR adds close_cross_thread_gate() immediately before the drain and rewrites the abort branch at 2481, so it's squarely in-scope per "fix the whole class in the same PR"; (c) the dispatch.rs:1166-1171 comment ("HTTP daemon is already parked so we hold the sole reference") is now false for the worker-shutdown caller.

    Fix

    Have the FetchTasklet arm of __bun_release_task_at_shutdown also do (*task.ptr.cast::<FetchTasklet>()).has_schedule_callback.store(false, Ordering::Relaxed) before the deref(). Then the next HTTP-thread callback wins the CAS, calls enqueue_concurrentNone (gate closed), and hits the schedule_shutdown abort at line 2481 that this PR already adds. This is a one-line change that reuses the abort machinery this PR built. The comment at dispatch.rs:1166-1171 should also be updated to note that the HTTP daemon is not parked on the worker-shutdown path.

Comment thread src/runtime/dns_jsc/dns.rs

@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: 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 `@src/threading/ShutdownGate.rs`:
- Around line 68-109: Update the close_and_wait_drains_racing_guests test to
deterministically hold at least one guest inside before starting closure, then
invoke close_and_wait concurrently from two waiter threads and release the
in-flight guest so both waiters must drain and return. Retain the post-close
assertions and ensure the workload exercises rejected entries by asserting
rejected is greater than zero.
🪄 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: f847e941-f3a8-4f0e-8087-ff61e5be62d8

📥 Commits

Reviewing files that changed from the base of the PR and between 2136034 and a15a161.

📒 Files selected for processing (7)
  • src/jsc/web_worker.rs
  • src/runtime/crypto/PasswordObject.rs
  • src/runtime/dns_jsc/dns.rs
  • src/runtime/node/node_fs.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • src/runtime/webcore/s3/client.rs
  • src/threading/ShutdownGate.rs
💤 Files with no reviewable changes (2)
  • src/runtime/crypto/PasswordObject.rs
  • src/runtime/webcore/s3/client.rs

Comment thread src/threading/ShutdownGate.rs
Comment thread src/threading/ShutdownGate.rs Outdated
Comment thread src/runtime/node/node_fs.rs Outdated
Comment thread src/jsc/vm_handle.rs Outdated
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated
Comment thread src/jsc/WorkTask.rs Outdated
Comment thread src/runtime/dispatch.rs
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
Comment thread src/runtime/webcore/s3/download_stream.rs Outdated
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated

@Jarred-Sumner Jarred-Sumner left a comment

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.

This PR is terrible code. Redo it.

Comment thread src/runtime/webcore/s3/download_stream.rs Outdated
Comment thread src/runtime/webcore/blob/write_file.rs Outdated
Comment thread src/runtime/node/zlib/NativeZlib.rs Outdated
Comment thread src/bundler/BundleThread.rs Outdated
Comment thread src/runtime/jsc_hooks.rs Outdated
Comment thread src/jsc/WorkTask.rs Outdated
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/jsc/Weak.rs Outdated
Comment thread src/runtime/jsc_hooks.rs Outdated
Comment thread src/runtime/node/node_zlib_binding.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/runtime/webcore/s3/client.rs Outdated
Comment thread src/runtime/node/node_fs.rs Outdated
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/vmhandle-worker-terminate-uaf branch from ce6b717 to 6598046 Compare July 15, 2026 19:06
Comment thread src/runtime/dns_jsc/dns.rs Outdated
Comment thread src/runtime/webcore/s3/simple_request.rs Outdated
Comment thread src/runtime/webcore/s3/download_stream.rs Outdated
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Pushed b42566d — rebased on current main and an idiomatic-Rust pass:

  • The 8 CI test failures were stale worktree edits that a git add -A had swept into the previous commit (mass test-file modifications from an unrelated session, including a stale mimalloc hash in process.test.js). All restored to main; the commit now touches only the 45 intended files.
  • No more *mut c_void in any public surface I added. The abort registry is now TerminateAbortRegistry holding Box<dyn FnOnce()> actions with typed register/unregister keyed by the producer allocation — the erased fn-pointer pairs and all three abort_for_terminate_erased shims are gone; producers register plain closures.
  • S3's context-release plumbing collapsed into one ContextRelease type with typed constructors (NONE / drop_box::<T>() / of(fn(*mut T))) — deleted the nine hand-written release_unrun shims across Blob/Store/S3File/client.
  • ManagedTask/ConcurrentTask cleanup parameters are typed fn(*mut T) (erasure internal, via the in-tree cast_fn_ptr, no transmute); the hand-built AnyTask literals in valkey/stat-watcher/libuv-dns use from_typed_with_dispose with named typed fns instead of casting closures.
  • No scopeguard added anywhere in the diff (the one grep hit is a pre-existing line in context).

Suite 9/9, 15-scenario LSan matrix clean, clippy clean, check-all 10/10 on the pushed head.

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/jsc/web_worker.rs:1283 — The first line of this comment block was accidentally dedented to column 0 (// Stop cross-thread posters first: markTerminating() serializes) while its continuation lines at :1284-1290 keep their 12-space indent, visually breaking the block inside a deeply-nested scope. rustfmt does not reflow // comment indentation, so this won't be auto-fixed — restore the 12-space indent.

    Extended reasoning...

    What changed

    The diff at src/jsc/web_worker.rs:1283 shows:

    -            // Stop cross-thread posters first: markTerminating() serializes
    +// Stop cross-thread posters first: markTerminating() serializes
                 // with postTaskTo() on the contexts-map lock, so after this call
                 // every task another thread has already enqueued is visible to the
                 // drain below and no new one can land. teardownJSCVM() will call

    Line 1283 lost its 12-space leading indent and now sits at column 0. The continuation lines (:1284-1290) keep their original 12-space indentation. This code lives inside impl WebWorker's terminate path, several nesting levels deep — the surrounding statements (rare.close_all_socket_groups(...) above and ScriptExecutionContext__markTerminating(...) below) are both at 12-space indent.

    Why it happened

    This PR inserts a new abort_pending_transfers / close_and_wait block immediately after this comment (the ScriptExecutionContext__markTerminating call is followed by the new abort-registry walk and gate close). The dedent is a stray whitespace edit from that insertion — nothing in the semantic change touches this comment line's content.

    Why it won't self-heal

    rustfmt does not reflow // line-comment indentation inside function bodies; it preserves whatever indentation the author wrote. So neither cargo fmt nor CI's format check will restore this — it will persist in the tree until manually fixed.

    Impact

    Formatting only — no runtime effect. The comment block is visually broken (first line flush-left, next seven lines indented), which reads as if the first line is a stray file-level note rather than the head of the block explaining why markTerminating() precedes the drain.

    Step-by-step proof

    1. Before this PR, line 1283 reads // Stop cross-thread posters first: markTerminating() serializes — 12 leading spaces, matching lines 1284-1290 and the surrounding statements.
    2. The diff hunk shows exactly one change to this line: - // Stop ...+// Stop .... No content change; only the 12 leading spaces are removed.
    3. Lines 1284-1290 are untouched (no +/-) and retain 12-space indent.
    4. The next code line, ScriptExecutionContext__markTerminating(vm.global());, is at 12-space indent, confirming the enclosing block depth.
    5. rustfmt treats // comments as opaque tokens on their own lines and does not adjust their leading whitespace, so bun run rust:fmt / cargo fmt will not restore the indent.

    Fix

    Restore the 12-space indent on line 1283:

                // Stop cross-thread posters first: markTerminating() serializes

Comment thread src/jsc/event_loop.rs
Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts
Comment thread src/runtime/dispatch.rs
Comment thread src/runtime/server/RequestContext.rs
Comment thread src/runtime/node/node_fs_stat_watcher.rs

@Jarred-Sumner Jarred-Sumner left a comment

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.

Clean up the code. This is still slop.

worker.terminate() freed the VirtualMachine while work-pool / HTTP-thread
completions still held raw pointers into it; the late enqueue into the
freed event loop's concurrent queue corrupted whatever reused the slab
(heap-use-after-free on Bun Pool, StructureID asserts, or an unwrap panic
in AsyncFSTask::run_from_js_thread depending on timing).

Pin producers: VirtualMachine owns an Arc<ShutdownGate>; vm.pin() returns
an RAII GateGuest. Every cross-thread producer (node:fs ops, recursive
cp/readdir, dns, zlib/brotli/zstd writes, Bun.password, pbkdf2 and the
generic AnyTaskJob, napi async work, Archive, Bun.build, stat-watcher,
runtime transpiler, fetch, S3) takes a pin at creation on the JS thread
and drops it on the completing thread after the completion enqueue.

Terminate aborts registered in-flight transfers (fetch/S3) so their pins
drop promptly, closes the gate and waits for all pins, then reclaims
queued completions per-tag with JSC still alive, then tears down. Main-VM
exit closes without waiting (its box is never freed).

AsyncFSTask::run_from_js_thread bails early when the Strong is empty or
the VM is stopping, so the drain-race panic cannot abort the process.

Rebased onto 47597ab and addresses the review on #34154: InitialStatTask
pinned, FetchTasklet::release_at_shutdown pin-drop ordering, duplicate
clippy allow, stale event_loop/stat-watcher doc comments. The drain-time
leak residuals (AsyncCpTask/TranspilerJob/RequestContext) stay in Known
residuals.

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
@robobun
robobun force-pushed the claude/vmhandle-worker-terminate-uaf branch from 8ece895 to e9c14e1 Compare July 22, 2026 16:27
@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto 47597ab as a single commit (e9c14e1) and addressed the open review threads.

What changed vs. the previous push:

  • InitialStatTask pinned (same-class UAF, previously uncovered)
  • FetchTasklet::release_at_shutdown pin-drop ordering fixed
  • AsyncFSTask::run_from_js_thread bails early on an empty promise Strong or a stopping VM (avoids the worker-thread unwrap panic)
  • any_task_job uses script_execution_status() (has_termination_request was removed in Remove ~39k lines of dead Rust across the workspace #35002)
  • Stale doc comments in event_loop.rs / node_fs_stat_watcher.rs, duplicate #[allow] in simple_request.rs, and four dead-on-main resurrections dropped

Verification:

  • Release-build repro (continuous fs readFile/writeFile/copyFile in a worker, terminate loop): segfaults at 0x8 after round 1 unfixed, completes all 12 rounds fixed
  • worker-terminate-lifetime.test.ts: 3 fail (ASAN heap-use-after-free in Bun.password / node:crypto / node:dns) on main src/, 11/11 pass with the fix
  • rust:check-all: 10/10 targets
  • worker.test.ts 25/25, fs.test.ts 427/427

Drain-time leak findings (AsyncCpTask / TranspilerJob / RequestContext S3 ctx / StatWatcher restat) stay in Known residuals; they are bounded per-terminate leaks, not crashes. Backup branch at farm/699dc78f/worker-terminate-fs-uaf.

Comment on lines +168 to +172
async () => {
using scratch = tempDir("worker-terminate-fs", {
big: Buffer.alloc(4 << 20, 0x5a).toString("binary"),
});
await using proc = Bun.spawn({

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 tempDir("worker-terminate-fs", { big: Buffer.alloc(4 << 20, ...) }) at :168-170 runs for all 6 workPoolJobs entries, but only the "node:fs readFile/writeFile/copyFile" entry reads process.env.FS_SCRATCH — the other five never touch it, so that's 5 unneeded 4MB alloc→toString('binary')→write→cleanup cycles per suite run. Trivially avoidable by gating on job.includes("FS_SCRATCH") (or moving the readFile/writeFile/copyFile case out of the loop); small in absolute terms vs. each test's worker-churn cost, so not blocking.

Extended reasoning...

What the inefficiency is

The for (const [name, job] of workPoolJobs) loop at worker-terminate-lifetime.test.ts:165 wraps every test body in:

using scratch = tempDir("worker-terminate-fs", {
  big: Buffer.alloc(4 << 20, 0x5a).toString("binary"),
});

and passes FS_SCRATCH: String(scratch) in env unconditionally. But workPoolJobs has 6 entries, and only the last one — "node:fs readFile/writeFile/copyFile" (:157-163) — reads process.env.FS_SCRATCH in its worker source. The other five entries (Bun.password, node:zlib, node:fs stat, node:dns, node:crypto) never touch the scratch dir or the env var.

Why nothing else needs it

Reading each workPoolJobs source string:

  • Bun.password: Bun.password.hash('hunter2', ...) — no filesystem access.
  • node:zlib: gzip(Buffer.alloc(1 << 16, 7), swallow) — in-memory buffer.
  • node:fs: stat(process.execPath) — reads the bun binary, not FS_SCRATCH.
  • node:dns: lookup('localhost') — no filesystem access.
  • node:crypto: pbkdf2(...) — in-memory.
  • node:fs readFile/writeFile/copyFile: const f = process.env.FS_SCRATCH; fsp.readFile(f + '/big')... — the only consumer.

So 5 of 6 iterations create a temp dir, allocate a 4MB Buffer, convert it via toString('binary') (a per-byte Latin1 decode into a JS string in the debug/ASAN test-runner process), write it to disk, and clean it up on scope exit — all for nothing.

Impact (small)

The absolute cost is modest: ~5 × (4MB Buffer.alloc + toString('binary') + mkdtemp + 4MB write + rm), on the order of tens of ms total. Each of these tests already spawns a subprocess that churns perRound (12 or 32) worker VMs serially with await Bun.sleep(i % 4) between them and a 20-60s timeout, so the wasted setup is ≪1% of wall-clock. The debug+ASAN slowdown does not proportionally amplify disk I/O; it's the Buffer.alloc/toString in the ASAN-instrumented test-runner process that's the marginally slower part. REVIEW.md's "Keep tests fast (~1s per test)" / "a new file over ~10s on the default lane gets scrutinized" applies in spirit, but this is not what's making the file slow.

Step-by-step proof

  1. worker-terminate-lifetime.test.ts:150-164 — workPoolJobs array with 6 [name, source] tuples.
  2. :165 — for (const [name, job] of workPoolJobs) { — iterates all 6.
  3. :168-170 — inside the test body (so it runs once per test, 6× total): using scratch = tempDir("worker-terminate-fs", { big: Buffer.alloc(4 << 20, 0x5a).toString("binary") }).
  4. :194 — env: { ...bunEnv, FS_SCRATCH: String(scratch) } — passed to every spawned child.
  5. Grep the 6 source strings for FS_SCRATCH: only the entry at :157-163 ("node:fs readFile/writeFile/copyFile") contains it. The other 5 sources never read the env var, so the scratch dir is created and torn down without effect for those iterations.

Fix

Gate the tempDir on the one entry that needs it, e.g.:

using scratch = job.includes("FS_SCRATCH")
  ? tempDir("worker-terminate-fs", { big: Buffer.alloc(4 << 20, 0x5a).toString("binary") })
  : { path: "", [Symbol.dispose]() {} };

or hoist the readFile/writeFile/copyFile case into its own test(...) outside the loop. Either is a few-line change with no behavior impact on the other five tests.

Comment on lines +333 to +340
// Reclaimed unrun by the terminate drain: the box owns no refs (the
// scheduler is kept alive by the StatWatcher's RefPtr) — plain free.
fn release_unrun(holder: *mut Holder) {
// SAFETY: queue-owned box popped by the drain; sole owner.
drop(unsafe { bun_core::heap::take(holder) });
}
// SAFETY: `holder_ptr` was just `heap::alloc`'d and is exclusively
// owned here until `update_timer` (or the drain) reclaims it.

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 pre-existing 3-line SAFETY comment at :330-332 ("holder_ptr was just heap::alloc'd … vm is the live per-thread VM (JSC_BORROW). addr_of_mut! so the field pointer inherits whole-Box provenance") was left in place when fn release_unrun (with its own doc at :333-334) was inserted between it and the unsafe {} block it documented, and a replacement SAFETY was added at :339-340. Lines 330-332 now sit directly above release_unrun and describe nothing there — release_unrun takes holder: *mut Holder, has no vm reference, and contains no addr_of_mut!. Same "new fn inserted between comment and target" class as the doc-misattachment batches already flagged in this PR (BundleThread.rs, jsc_hooks.rs, VirtualMachine.rs), at a distinct site not covered by any of them. Comment-only; delete :330-332.

Extended reasoning...

What was left behind

The diff to node_fs_stat_watcher.rs inserted a nested fn release_unrun between a pre-existing // SAFETY: block and the unsafe {} block that comment documented, then added a shortened replacement SAFETY comment above the unsafe {}. The result at HEAD (node_fs_stat_watcher.rs:330-341):

// SAFETY: `holder_ptr` was just `heap::alloc`'d and is exclusively owned here
// until `update_timer` reclaims it; `vm` is the live per-thread VM (JSC_BORROW).
// `addr_of_mut!` so the field pointer inherits whole-Box provenance.
// Reclaimed unrun by the terminate drain: the box owns no refs (the
// scheduler is kept alive by the StatWatcher's RefPtr) — plain free.
fn release_unrun(holder: *mut Holder) {
    // SAFETY: queue-owned box popped by the drain; sole owner.
    drop(unsafe { bun_core::heap::take(holder) });
}
// SAFETY: `holder_ptr` was just `heap::alloc`'d and is exclusively
// owned here until `update_timer` (or the drain) reclaims it.
unsafe {

Lines 330-332 (the first // SAFETY: block) are now orphaned. They read as part of release_unrun's leading comment block (Rust groups adjacent line comments visually), but describe three things that do not exist in release_unrun: holder_ptr (release_unrun's parameter is holder: *mut Holder), vm (release_unrun never touches a VM reference), and addr_of_mut! (release_unrun's body is a single heap::take).

Why nothing else explains it

The diff itself shows the mechanism plainly: the pre-diff hunk had // SAFETY: holder_ptr … vm … addr_of_mut! immediately above unsafe { (*holder_ptr).task = AnyTask { … }; … }. The PR added release_unrun at :333-338 and a new // SAFETY: at :339-340, but did not delete the old comment above the insertion point. Lines 339-340 are a shortened rewrite of :330-331 ("holder_ptr was just heap::alloc'd and is exclusively owned here until update_timer (or the drain) reclaims it") — the vm/addr_of_mut! justifications were dropped even though the unsafe {} block at :341-350 still dereferences (*this).vm and uses core::ptr::addr_of_mut! at :348, but the duplication of the holder_ptr clause at both :330 and :339 is what proves :330-332 was not intentionally kept as extra documentation.

The PR description's Rebase notes claim "stale doc comments in event_loop.rs / node_fs_stat_watcher.rs updated" — this one was missed in that pass.

Why it's a distinct site

This PR's review timeline already carries doc-misattachment findings of the "new fn inserted between comment and target" class at BundleThread.rs, jsc_hooks.rs, VirtualMachine.rs, simple_request.rs, client.rs, Blob.rs, and event_loop.rs. This site — node_fs_stat_watcher.rs:330-332 — is not enumerated in any of those threads: the two prior comments on this file are on line 419 (the _vm_pin/restat cleanup leak) and the earlier (now-resolved) InitialStatTask unpinned finding, neither of which touches set_timer's SAFETY block.

Step-by-step proof

  1. node_fs_stat_watcher.rs:330-332 — // SAFETY: holder_ptr was just heap::alloc'd … vm is the live per-thread VM (JSC_BORROW). addr_of_mut! so the field pointer inherits whole-Box provenance.
  2. node_fs_stat_watcher.rs:333-338 — // Reclaimed unrun by the terminate drain: … fn release_unrun(holder: *mut Holder) { … heap::take(holder) … }. No holder_ptr identifier, no vm access, no addr_of_mut!. Lines 330-332 describe none of it.
  3. node_fs_stat_watcher.rs:339-340 — // SAFETY: holder_ptr was just heap::alloc'd and is exclusively owned here until update_timer (or the drain) reclaims it. — a rewrite of :330-331 with "or the drain" added, placed at the correct location above unsafe { at :341.
  4. The diff confirms the ordering: the removed hunk had // SAFETY: … addr_of_mut! … directly above unsafe { (*holder_ptr).task = AnyTask { ctx: …, callback: update_timer } }; the added hunk inserts release_unrun and the new SAFETY between them but keeps the old three lines as context.

Impact and fix

Comment-only — no runtime effect. A reader following release_unrun's comment block sees a SAFETY justification citing invariants that don't apply to the function beneath it, which REVIEW.md's comment guidance ("comments carry only durable non-obvious content") flags as noise. Fix: delete lines 330-332. Optionally, restore the dropped vm/addr_of_mut! clauses to :339-340 since the unsafe {} block at :341-350 still relies on both, but the orphan itself is the finding here.

@Jarred-Sumner Jarred-Sumner left a comment

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.

There is too much unsafe and type system workarounds in this PR. Can you make this more idiomatic Rust?

robobun added a commit that referenced this pull request Aug 3, 2026
Read-direction coverage for the same pinArrayBuffer ref fix. The kernel's
copy_to_user into the destination is invisible to ASAN, so the oracle is a
direct address probe: the worker reports ptr(buf) before its fs.read/readv
parks on an empty FIFO, the parent terminates it, and bun:ffi read.u8 at
that address either sees the worker's fill byte (storage alive) or trips
ASAN heap-use-after-free via GCIncomingRefCountedSet::lastChanceToFinalize
(storage freed mid-read). Linux+ASAN only; the FIFO is never written so the
pool thread stays in read(2) and the separate #34154 completion crash is
not reached.
@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Connecting this PR to production crash telemetry: this fix covers what is currently the largest native crash family in Sentry for the 1.4.0 canaries.

While investigating BUN-3PWA (bun_sys::windows_impl::write segfault, 467 events, all Windows) the trail led here. The family, all from claude-code standalone builds (which spawn and terminate worker_threads), all 1.4.0 canaries:

  • BUN-3PWA: pool-thread segfault inside async fs.writeFile, WriteFile called with a garbage source buffer. Fault addresses are 0x0 (60%), 0xFFFFFFFFFFFFFFFF, or DLL-image-range pointers, i.e. the freed AsyncFSTask args reread after the allocation was reused.
  • BUN-40F7 (38), BUN-3SXR (19) on Windows and BUN-40GS (62), BUN-3QRN (32), BUN-3YQZ (22) on macOS: main-thread segfault in AsyncFSTask::run_from_js_thread at fault address 0x48 (Windows) / 0x68 (macOS). These decode as a zeroed ConcurrentTask node: TaskTag(0) is Access and the offset is the result field, so the "Access task at null" is a clobbered queue node, matching the late-enqueue corruption described in this PR.
  • BUN-432P (7, macOS): args::Symlink task with a wild pointer fault.
  • BUN-3PRF (61, Windows): dns getaddrinfo work_pool_callback reading a freed Request.

Roughly 700 events across the family, ongoing daily.

Verification done today:

  • Repro: worker churn (4 workers doing async fs.promises ops, terminated after 10-30ms, in a loop) crashes the current canary release (b66764f) in about 1.5 seconds with Segmentation fault at address 0x0 and "multiple threads are crashing", the same signature as the telemetry.
  • This branch (e9c14e1): the same repro survives 139 iterations over 60 seconds under the debug+ASAN build with no reports, and test/js/web/workers/worker-terminate-lifetime.test.ts passes 11/11.
  • Branch state: 367 commits behind main at ef32923 but git merge-tree reports a clean merge, no conflicts.

Landing this closes the whole family.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants