Skip to content

fetch: fence HTTP-thread FetchTasklet callbacks against worker VM dealloc - #36575

Closed
robobun wants to merge 7 commits into
mainfrom
farm/fe0c9ca0/fetch-worker-terminate-uaf
Closed

fetch: fence HTTP-thread FetchTasklet callbacks against worker VM dealloc#36575
robobun wants to merge 7 commits into
mainfrom
farm/fe0c9ca0/fetch-worker-terminate-uaf

Conversation

@robobun

@robobun robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Crash

A worker reaching WebWorker::shutdown with fetch() requests in flight crashes the whole process. Stock canary SIGSEGVs; ASAN builds report heap-use-after-free 8/8:

READ of size 1 ... thread T22 (HTTP Client)
  #0 VirtualMachine::is_shutting_down      src/jsc/VirtualMachine.rs:979
  #1 FetchTasklet::callback                src/runtime/webcore/fetch/FetchTasklet.rs:2652
     (or FetchTasklet::deref_from_thread   src/runtime/webcore/fetch/FetchTasklet.rs:400)
freed by thread T25 (Worker):
  #3 WebWorker::shutdown                   src/jsc/web_worker.rs:1383
     (std::alloc::dealloc of the VirtualMachine storage)

All four shutdown doors trigger it: the parent calling worker.terminate(), and the worker ending itself via process.exit(), an uncaught throw, or an unhandled rejection. process.exit() in a worker is WebWorker::exit() = set_requested_terminate(), and both the entry-promise-rejected and uncaught-exception paths in spin() fall through to the same shutdown(); the dealloc at web_worker.rs:1383 is the same in every case.

Repro

Worker opens 10 lanes of back-to-back fetches against a local trickle-stream server and exits mid-stream (any door), repeat:

const server = Bun.serve({ port: 0, fetch: () => /* 200 chunks, 2ms apart */ });
for (let r = 0; r < 8; r++) {
  const w = new Worker(src, { eval: true, workerData: { base } });
  await new Promise(res => w.once("message", res));
  await Bun.sleep(60 + ((r * 37) % 200));
  await w.terminate();            // or worker's own process.exit / throw / reject
  await fetch(base + "/health");  // keep-alive pool still healthy
}

Cause

FetchTasklet.javascript_vm is a lifetime-erased &'static VirtualMachine. The HTTP client thread is process-shared and reads that reference in callback, deref_from_thread, and on_write_request_data_drain to check is_shutting_down() and to enqueue_task_concurrent(). A worker's VirtualMachine (with the event loop and concurrent-task queue embedded in it) is raw-dealloc'd by WebWorker::shutdown(), with no fence against those HTTP-thread readers. The main-thread global_exit() already has exactly that fence: bun_http::shutdown_for_exit() parks the HTTP daemon before release_queued_tasks_for_shutdown and the VM free. Worker shutdown has no counterpart, and shutdown_for_exit() is a process-global one-shot so it cannot be reused per-worker.

Fix

Give each VM an Arc<CrossThreadShutdownSignal> (AtomicBool flag + reader count) and have every FetchTasklet clone it at creation. The three HTTP-thread entry points replace javascript_vm.is_shutting_down() with try_begin_vm_read(): the flag is read from the Arc (safe after dealloc), and on true a reader count is held across the enqueue_task_concurrent so worker shutdown spins it out before freeing the VM. Worker shutdown marks the flag before on_exit() and waits for readers to drain right before release_queued_tasks_for_shutdown, so any task a reader enqueued is caught by the drain. The fence sits in WebWorker::shutdown itself, so it covers every door.

A tasklet whose last ref drops on the HTTP thread after a worker VM has shut down is leaked rather than parked in the process-exit defer_shutdown_reclaim list (the parked deinit() would dereference JSC handles in freed storage). The large body buffers are released before the leak; main-thread shutdown still uses the existing park-and-drain path.

Relationship to #34154 / #35767

#34154 (and #35767) are the general fixes covering every cross-thread producer (fetch, S3, work pool, napi, fs watchers, the waiter thread). Both are currently conflicting. This PR is a focused stopgap for the fetch slice so the process-killing crash stops landing; either supersedes it when it lands.

Verification

test/js/web/workers/worker-terminate-lifetime.test.ts gains an ASAN-gated describe that runs the repro once per shutdown door (terminate(), process.exit(), uncaught throw, unhandled rejection) in a subprocess and asserts clean stderr.

  • Without the fix: 4/4 FAIL with the ASAN report above.
  • With the fix: 4/4 PASS (~17s concurrent under debug+ASAN).
  • test/js/web/workers/worker.test.ts: 25 pass / 0 fail.
  • test/js/node/worker_threads/worker_threads.test.ts: 91 pass / 0 fail.
  • cargo clippy -p bun_jsc -p bun_runtime: clean.

no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/workers/worker-terminate-lifetime.test.ts

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The VM now uses an Arc-backed shutdown signal to fence cross-thread HTTP callbacks. Worker teardown marks shutdown, waits for active readers, and then releases VM resources. An ASAN-only test covers repeated termination during streaming fetches.

Changes

Worker shutdown and HTTP callback lifetime

Layer / File(s) Summary
VM shutdown signal and lifecycle
src/jsc/VirtualMachine.rs
CrossThreadShutdownSignal tracks shutdown state and active readers. VirtualMachine creates, exposes, marks, and releases the signal during its lifecycle.
FetchTasklet callback fencing
src/runtime/webcore/fetch/FetchTasklet.rs
Fetch callbacks, request-body drains, and cleanup use VM read guards. Cleanup differs for main-thread and worker VMs during shutdown.
Worker teardown coordination and regression coverage
src/jsc/web_worker.rs, test/js/web/workers/worker-terminate-lifetime.test.ts
Worker shutdown waits for HTTP callback readers before draining tasks. An ASAN-only test exercises concurrent streaming fetches during repeated worker termination.

Possibly related issues

  • oven-sh/bun#33911 — Addresses the worker termination race involving in-flight fetch() callbacks through shutdown signaling and reader synchronization.

Possibly related PRs

  • oven-sh/bun#35093 — Both changes modify FetchTasklet fetch lifecycle handling, but this PR targets worker shutdown safety.
  • oven-sh/bun#36097 — Both changes update WebWorker::shutdown and worker termination regression coverage.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 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 clearly and concisely describes the fetch callback lifetime fence added for worker VM deallocation.
Description check ✅ Passed The description explains the crash, cause, fix, scope, regression test, and verification results, covering the template requirements.

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

Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/web_worker.rs Outdated
Comment thread src/jsc/web_worker.rs Outdated
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/jsc/web_worker.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(fetch): remove latent cross-thread UB in FetchTasklet shutdown #30943 - Fixes the same cross-thread UB in FetchTasklet shutdown path
  2. fetch: encode FetchTasklet's cross-thread ownership in the type system #31745 - Encodes FetchTasklet cross-thread ownership in the type system to make the UAF structurally impossible
  3. fetch: hold FetchTasklet mutex through deref_from_thread so HTTP thread is never the final deref #32707 - Holds FetchTasklet mutex through deref_from_thread so the HTTP thread is never the final deref after worker VM is freed
  4. Fix use-after-free when worker.terminate() races in-flight fetch/work-pool completions #34154 - Introduces ShutdownGate to fence worker.terminate() against in-flight fetch/work-pool completions (identical problem, broader scope)
  5. worker: post every cross-thread completion by ScriptExecutionContext id so terminate() cannot UAF the freed VM #35767 - Routes all cross-thread completions (including FetchTasklet) through ScriptExecutionContext id to prevent terminate() UAF

🤖 Generated with Claude Code

@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

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

⚠️ Outside diff range comments (1)
src/runtime/webcore/fetch/FetchTasklet.rs (1)

399-418: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Hold the signal in a local before enqueueing the deinit task.

At this point release() already returned true, so the HTTP thread owns the last reference and hands ownership to the JS thread through ConcurrentTask::from_callback(this, FetchTasklet::deinit_callback). The JS thread can run deinit_callback as soon as the task is enqueued. deinit() calls bun_core::heap::take(this) and drops the box, including the vm_shutdown_signal field. Line 417 then dereferences self_.vm_shutdown_signal on freed memory.

Two failures follow:

  1. Use-after-free of the Arc field (and of the whole FetchTasklet allocation) on the HTTP thread.
  2. If the drop wins the race, end_vm_read() never runs against the live signal, so readers stays above zero and wait_for_readers() in WebWorker::shutdown spins forever.

Clone the Arc into a local while the tasklet is still alive, then enqueue, then end the read section through the local.

Note the other two fenced sites are not affected: on_write_request_data_drain takes ref_() before enqueueing, and callback still holds the HTTP-side ref past end_vm_read().

🐛 Proposed fix
         // this is really unlikely to happen, but can happen
         // lets make sure that we always call deinit from main thread
         // `from_callback` heap-allocates a fresh `ConcurrentTaskItem`; the queue
         // takes ownership of it.
+        // The enqueued task may run and free `this` before we return, so keep
+        // our own handle on the signal to close the read section.
+        let signal = std::sync::Arc::clone(&self_.vm_shutdown_signal);
+        let vm = self_.javascript_vm;
         Self::enqueue_concurrent(
-            self_.javascript_vm,
+            vm,
             ConcurrentTask::from_callback(this, FetchTasklet::deinit_callback),
         );
-        self_.vm_shutdown_signal.end_vm_read();
+        signal.end_vm_read();
🤖 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/webcore/fetch/FetchTasklet.rs` around lines 399 - 418, Clone
self_.vm_shutdown_signal into a local Arc before calling
ConcurrentTask::from_callback or enqueue_concurrent, while the FetchTasklet
allocation is still alive. Enqueue the deinit task as before, then call
end_vm_read() through the local clone so the signal remains valid even if
deinit_callback drops the tasklet immediately.
🤖 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.

Outside diff comments:
In `@src/runtime/webcore/fetch/FetchTasklet.rs`:
- Around line 399-418: Clone self_.vm_shutdown_signal into a local Arc before
calling ConcurrentTask::from_callback or enqueue_concurrent, while the
FetchTasklet allocation is still alive. Enqueue the deinit task as before, then
call end_vm_read() through the local clone so the signal remains valid even if
deinit_callback drops the tasklet immediately.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2ce49b9f-9d29-4047-8c34-a4b4978e9ccc

📥 Commits

Reviewing files that changed from the base of the PR and between f68e504 and 7bedb37.

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

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed review so far:

CodeRabbit (deref_from_thread, lines 399-418): fixed in 4477236. The enqueued deinit_callback can free the tasklet before end_vm_read() runs, so the signal Arc and javascript_vm are now snapshotted into locals before the enqueue. Good catch.

comment-cop: trimmed in fb9d622; the check passes. The remaining flags are on the CrossThreadShutdownSignal struct/method docs (the memory-ordering contract and the try_begin_vm_read/end_vm_read pairing rule) and the deliberate-leak note in dealloc_for_shutdown. These document concurrency invariants, not workarounds; the neighboring code in both files carries comparable rationale comments. Resolving as intentional.

find-duplicate-prs-bot: this PR is the narrow fetch-only stopgap for the worker.terminate() + in-flight fetch() crash. The general fix for the whole cross-thread-producer class is #32071 (live-VM registry); #34154 and #35767 are alternative general fixes that also cover the work pool and other producers; #31745 is a type-system refactor of FetchTasklet ownership; #30943 targets a different shutdown-path UB; #32707 reorders the mutex/deref in callback for a separate refcount assertion. None of those is currently mergeable, which is why this scoped change exists.

Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated

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

The UAF I flagged in deref_from_thread is fixed in 4477236 — the signal Arc and javascript_vm are now snapshotted to locals before the enqueue, and end_vm_read() runs on the local clone. I found nothing further this round, but this is cross-thread lifetime code introducing a new synchronization primitive (Dekker-style reader fence + spin-wait + deliberate leak on the worker path) and one of six open PRs attacking the same bug class, so a maintainer should decide which approach lands.

What was reviewed:

  • deref_from_thread after the 4477236 fix — locals hoisted before enqueue, no field read of *this past the hand-off.
  • callback and on_write_request_data_drainend_vm_read() reads task_ref/this_ref while the HTTP-side ref (or the extra ref_()) is still held, so the tasklet cannot be freed under it.
  • SeqCst ordering on try_begin_vm_read / mark_shutting_down / wait_for_readers — the four-op Dekker fence holds; end_vm_read's release makes the enqueue visible to release_queued_tasks_for_shutdown.
  • dealloc_for_shutdown reads is_main_thread() from the tasklet's own Arc field while the box is still live (ref_count == 0 but not yet freed).
Extended reasoning...

Overview

Adds an Arc<CrossThreadShutdownSignal> (atomic flag + reader count) to VirtualMachine, has each FetchTasklet clone it, and replaces the three HTTP-thread javascript_vm.is_shutting_down() reads with a try_begin_vm_read() / end_vm_read() bracket around enqueue_task_concurrent. WebWorker::shutdown marks the flag before on_exit() and spin-waits readers to zero right before release_queued_tasks_for_shutdown, so any concurrently-enqueued task is caught by the drain. dealloc_for_shutdown now leaks the tasklet on worker VMs (parking would deref freed JSC handles); large buffers are released by the caller first. An ASAN-gated subprocess test reproduces the original UAF.

Security risks

None user-facing; this is internal lifetime management. The failure mode being fixed is a process crash (DoS-ish for a server hosting workers), not data exposure or auth.

Level of scrutiny

High. This is hand-rolled cross-thread synchronization on a hot shutdown path in VirtualMachine / WebWorker / FetchTasklet — exactly the memory-safety category the review guide flags as most-blocked. It introduces a new primitive with a documented Dekker-style SeqCst fence, a busy spin-wait in worker teardown, and an intentional leak. The find-duplicate-prs bot lists five other open PRs (#30943, #31745, #32707, #34154, #35767) plus the general #32071 that this PR says will supersede it — which of these lands is a maintainer call, not something to auto-approve.

Other factors

My previous inline finding (reading self_.vm_shutdown_signal after the deinit enqueue in deref_from_thread) was addressed in 4477236. I re-checked the two other end_vm_read() sites: in callback the HTTP-side ref is held past end_vm_read() (deref runs after mutex.unlock()), and in on_write_request_data_drain an explicit Self::ref_() precedes the enqueue. The SeqCst placement on all four fence ops is correct for the stated invariant, and the end_vm_read fetch_sub's release semantics make the preceding enqueue visible to the worker thread's post-wait_for_readers drain. The comment-cop flags remaining are on the concurrency-contract doc comments, which the author has justified as invariant documentation rather than workaround narration; that's a style call for the human reviewer. Test follows the file's existing subprocess/ASAN-gated pattern and drains stdout/stderr/exited concurrently.

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

A second producer of this bug class was found in the same fuzz round: RuntimeTranspilerStore's TranspilerJob running on the shared WorkPool thread after the worker VM (and its Transpiler) has been freed. Same free-side (WebWorker::shutdown / VirtualMachine::destroy), different off-thread holder.

That producer is intentionally out of scope for this PR. The CrossThreadShutdownSignal here is per-VM and a TranspilerJob fix could reuse it, but bracketing a full parse+print with try_begin_vm_read/end_vm_read would make worker.terminate() spin on a running transpile; the job needs a cancel point or a queue drain instead. Tracked separately. #32071 (the registry-wide fix) covers the completion-enqueue half of both producers.

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for dfd74de (build #88110): all individual lane checks pass; only the aggregate is red. The new 4-door worker shutdown with fetch() in flight tests passed on the ASAN lane. Nothing tagged [new] touches the diff:

  • test/js/bun/spawn/spawn-maxbuf.test.ts ([new], debian-13-x64-asan): toBeLessThan(100) received 103. A 3ms timing miss on the spawn maxBuffer kill-window assertion; the diff does not touch spawn. Reported separately.
  • Everything else is [flaky] (passed alone or on retry).

Earlier build #86413 on the pre-rebase sha had the same shape (only red was the unrelated worker-transfer-terminate-stress assertNoException, also reported), and that test did not recur on #88110.

Ready for review.

robobun and others added 5 commits August 3, 2026 10:02
…lloc

A worker's VirtualMachine is raw-dealloc'd by WebWorker::shutdown while
the shared HTTP client thread can still deliver FetchTasklet callbacks
for that worker's in-flight requests. Those callbacks dereferenced
javascript_vm (a lifetime-erased &'static VirtualMachine) to read
is_shutting_down and to enqueue_task_concurrent, which is a
heap-use-after-free on the freed VM storage and crashes the whole
process.

Add a per-VM Arc<CrossThreadShutdownSignal> that tasklets clone at
creation. The three HTTP-thread entry points (callback,
deref_from_thread, on_write_request_data_drain) now read the shutdown
flag from the Arc and hold a reader count across the VM dereference.
WebWorker::shutdown marks the signal early and spins on the reader count
before freeing the VM, so every in-flight callback either observes
shutting_down and takes the early-return path or completes its enqueue
before the dealloc.

Tasklets orphaned by a dead worker VM are leaked rather than parked in
the process-exit reclaim list, since the parked deinit() would touch
JSC handles in freed storage; the large body buffers are released
before the leak.
The enqueued deinit_callback may free the tasklet before this function
returns, so reading self_.vm_shutdown_signal after the enqueue is a
use-after-free and could leave the reader count unbalanced.
…row/reject) for the fetch UAF

A worker can reach WebWorker::shutdown via four doors: parent terminate(),
worker process.exit(), an uncaught throw, or an unhandled rejection. All four
raw-dealloc the VirtualMachine while the shared HTTP thread may still be
inside FetchTasklet::callback reading it; the fence in shutdown() is
door-agnostic by construction.

The test matrix runs the fetch-in-flight repro once per door so the fence
is proven against each, not only terminate().
@robobun
robobun force-pushed the farm/fe0c9ca0/fetch-worker-terminate-uaf branch from 4477236 to 5fbe35b Compare August 3, 2026 10:24
@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:51 AM PT - Aug 3rd, 2026

@robobun, your commit dfd74de has 1 failures in Build #88110 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36575

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

bun-36575 --bun

@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (074656d) and extended the test to cover all four worker-shutdown doors: parent terminate(), and the worker ending itself via process.exit(), an uncaught throw, or an unhandled rejection. All four converge on the same WebWorker::shutdown dealloc and the fence is door-agnostic; the test matrix now proves it.

ASAN on main without the fix: 4/4 doors fail with heap-use-after-free at VirtualMachine::is_shutting_down. With the fix: 4/4 pass (~17s concurrent).

Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts Outdated
Matches the file's existing ready(w) pattern so a worker that fails
before posting 'up' rejects with a useful message instead of hanging
to the outer timeout. The error-swallowing handler for the
throw/reject doors moves to after the ready-wait.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No new issues found; both earlier findings (the deref_from_thread post-enqueue field read and the test's un-wired ready-wait) are addressed. This is cross-thread lifetime/memory-safety code with a new hand-rolled synchronization primitive, so it should get human eyes.

What was reviewed:

  • try_begin_vm_read/end_vm_read Dekker ordering (inc-then-load vs store-then-wait, all SeqCst) and that the reader window in each of the three HTTP-thread sites is bounded so wait_for_readers() can't spin indefinitely.
  • All three end_vm_read() call sites for tasklet-still-alive: deref_from_thread now uses stack-cloned signal; callback holds the HTTP-side ref past it; on_write_request_data_drain takes Self::ref_() before the enqueue.
  • mark_shutting_down() placement (before on_exit()) vs wait_for_readers() placement (immediately before release_queued_tasks_for_shutdown), so any task a reader enqueued in between is drained.
  • dealloc_for_shutdown main-thread vs worker branch — main-thread path unchanged, worker path leaks the box after buffers were freed by the caller.
Extended reasoning...

Overview

The PR adds a per-VM Arc<CrossThreadShutdownSignal> (atomic flag + reader count) so the process-shared HTTP client thread can safely check whether a worker's VirtualMachine is still alive before dereferencing it. FetchTasklet clones the Arc at creation and brackets each of its three HTTP-thread VM dereferences (callback, deref_from_thread, on_write_request_data_drain) with try_begin_vm_read()/end_vm_read(). WebWorker::shutdown marks the flag before running exit handlers and spin-waits for readers to hit zero right before draining the concurrent task queue and raw-deallocing the VM. dealloc_for_shutdown now leaks the tasklet box for worker VMs (the parked deinit() would touch freed JSC handles) while keeping the existing defer_shutdown_reclaim path for the main thread. An ASAN-gated test exercises all four worker-shutdown doors.

Security risks

None identified. This is a crash/UAF fix in internal lifetime management; no user-facing input parsing, auth, or trust boundaries are touched.

Level of scrutiny

High. This is exactly the category REVIEW.md flags as most-blocked: cross-thread lifetime, hand-rolled atomics with a stated memory-ordering argument, a deliberate leak, and a spin-wait with no timeout on the worker-shutdown path. The try_begin_vm_read fence is a Dekker-style increment-then-check against store-then-wait; getting the ordering or the placement of wait_for_readers() relative to release_queued_tasks_for_shutdown wrong reintroduces the UAF or leaks enqueued tasks. The PR is also explicitly a stopgap that will be superseded by #34154/#35767, which is a design call a maintainer should ratify.

Other factors

  • Two prior review findings on this PR were both addressed (the snapshot-before-enqueue in deref_from_thread, and the test's ready(w) helper).
  • CI on the earlier revision was green on the ASAN lane for the new test; the one red was a pre-existing unrelated failure.
  • The reader window at each HTTP-thread site is short and non-blocking (an MPSC push + wakeup), so wait_for_readers() should not spin long — but it has no bound, which is worth a maintainer's judgement.
  • The double mark_shutting_down() (once in WebWorker::shutdown, once via on_exit()) is idempotent; main-thread behavior in callback's shutdown branch is preserved because dealloc_for_shutdown still routes through defer_shutdown_reclaim when is_main_thread.

Deferring to human review.

@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #36983: the worker shutdown fence there spans the whole HTTP engagement of a FetchTasklet (queue() to the final is_done callback) via EventLoop.outstanding_offthread, adds an abort fan-out so terminate() does not wait out the transfer, and includes this PR's worker-path dealloc_for_shutdown leak fix and the four-door fetch test. Closing in favor of that PR.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants