workers: join in-flight transpiler jobs before freeing the VM on terminate - #33939
workers: join in-flight transpiler jobs before freeing the VM on terminate#33939robobun wants to merge 1 commit into
Conversation
|
Status: rebased onto Repro: PR: #33939. Waiting on CI. |
WalkthroughChangesTranspiler teardown synchronization
Possibly related issues
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 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/web_worker.rs`:
- Around line 1283-1290: Move the wait_for_inflight_jobs() call in the VM
shutdown path to immediately before the JSC VM teardown step, rather than after
teardown has started. Update the surrounding cleanup ordering in the relevant
web worker destruction routine so any TranspilerJob accessing VM state has
joined before WebWorker__teardownJSCVM, while retaining the later destroy()
safety wait if required.
In `@test/js/web/workers/worker-terminate-lifetime.test.ts`:
- Around line 141-159: Update the worker fixtures in main.js so import failures
are observable: post the caught import error from racer-worker.js and
ref-worker.js instead of swallowing it, and reject the corresponding promises
when either worker emits an error event. Ensure the terminated and refDone
handshakes await success messages while propagating all fixture failures
immediately.
- Around line 174-179: Update the worker termination test assertion to validate
the raw stderr value unconditionally before checking stdout and exitCode; remove
the AddressSanitizer-based ternary filtering so any unexpected child diagnostics
cause the test to fail, while preserving the expected stdout and zero exit code
checks.
🪄 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: ca3ee3fe-d2b4-42d0-be3f-9278f1321282
📒 Files selected for processing (4)
src/jsc/RuntimeTranspilerStore.rssrc/jsc/VirtualMachine.rssrc/jsc/web_worker.rstest/js/web/workers/worker-terminate-lifetime.test.ts
There was a problem hiding this comment.
No new issues found. f22c03e addresses the ConcurrentTask node portion of my earlier leak note by embedding the wakeup node in the store (auto_delete: false, so the tick's heap::take skips it) — the job-payload leak remains scoped out per the reply. The coalescing flag looks sound: it's cleared in run_from_js_thread only after concurrent_tasks.pop_batch() has already unlinked the node in the tick that dispatched it, so a re-enqueue can't double-link.
Deferring to a human on this one — cross-thread VM teardown ordering plus a new embedded intrusive-node coalescing scheme is the kind of change that benefits from a second pair of eyes.
Extended reasoning...
Overview
The PR fixes a heap-use-after-free where a TranspilerJob running on the shared work pool reads a worker's VirtualMachine allocation after worker.terminate() has freed it. Four files touched: RuntimeTranspilerStore.rs (+54, adds in_flight counter, wait_for_inflight_jobs, embedded dispatch_task node), VirtualMachine.rs (+6, join at top of destroy), web_worker.rs (+8, join before step-5 loop teardown), and a new ASAN-gated regression test.
Since my previous review (the leak note on web_worker.rs:1290), commit f22c03e replaced the per-dispatch Box<ConcurrentTask> with a single ConcurrentTaskItem embedded in the store, guarded by an AtomicBool coalescing flag.
What I checked in f22c03e
ConcurrentTaskItem::default()hasauto_delete: false, sotick_concurrent_with_count'sheap::takepath (event_loop.rs:508) skips the embedded node — no bogus free.- Re-enqueue safety: the flag is cleared in
run_from_js_thread, which is reached viadispatch.rs:351only after the tick has alreadypop_batch'd the node out ofconcurrent_tasksand moved its.tasktoself.tasks. So the intrusivenextlink is not in the queue when a producer wins the false→true swap and re-enqueues. The.taskwrite is ordered before the consumer's read by the queue's push-Release / pop-Acquire. init_in_place(currently unused) andDefaultboth initialize the three new fields.- The
fetch_sub(Release)inrun_from_worker_threadis placed afterrun()returns (whosedeferhas already calleddispatch_to_main_thread→ toucheddispatch_task/event_loop), so the pool thread's final VM access is indeed the decrement, matching the poll-not-wake rationale inwait_for_inflight_jobs.
Security risks
None user-facing; this is internal lifetime management. The pre-fix state was a UAF (worse).
Level of scrutiny
High. This sits in the numbered-barrier WebWorker::shutdown sequence, adds a futex-polled join on an atomic counter with a documented reason for not waking, and introduces a shared embedded intrusive node with a hand-rolled coalescing gate. Memory-safety-critical, cross-thread, and the correctness argument depends on the exact ordering of tick → pop_batch → dispatch → flag-clear.
Other factors
All CodeRabbit threads resolved (two applied, one withdrawn after verification). My earlier bounded-leak note is acknowledged as pre-existing and deferred to a follow-up alongside #32071. The new test is ASAN-gated with verified fail-on-main / pass-on-PR evidence in the description. Not approving because this is exactly the class of change (concurrency + VM teardown + memory safety) that should get a human maintainer's sign-off.
|
CI status: the only failure on build 71661 is test/js/bun/http/proxy-stress-concurrent.test.ts, a pre-existing 9 byte leak in the DNS pending cache (Resolver::get_or_put_into_pending_cache, dns.rs:4696) reported by LSAN on the x64-asan lane. That path is untouched by this diff (workers / transpiler store), and the PR's own test passed on this build after f22c03e removed the dispatch-node leak. Everything else is green; this is ready for review. |
…inate A TranspilerJob for a dynamic import() runs on the shared work pool and reads the owning VirtualMachine allocation throughout run(): the generation check, a ptr::read of the whole Transpiler (whose defines table the parser walks), test_isolation_enabled, source_mappings, and the job slot itself lives inside vm.transpiler_store (the first 64 slots are an inline HiveArray in the VM allocation, so a queued job's WorkPool link is inside that box too). worker.terminate() landing while the job is queued or running freed all of that in WebWorker::shutdown, so the pool thread kept reading freed memory. Track pool-side jobs with an in_flight counter on RuntimeTranspilerStore, incremented in schedule() (JS thread, before the job is handed to the pool) and decremented on the pool thread after run() and its dispatch back complete. VM teardown joins on the counter before freeing anything run() reads, in WebWorker::shutdown before step-5 loop teardown and at the top of VirtualMachine::destroy(). No new job can be scheduled while it blocks since schedule() only runs on the waiting thread. The waiter bumps generation_number first so jobs still queued on the pool bail at the top of run() instead of doing a full parse, and polls with a 1 ms futex timeout instead of a decrement-side wake: the worker thread frees the VM allocation (including the counter) right after the wait returns, so the pool thread's final VM access must be the fetch_sub itself. The dispatch wakeup is a single ConcurrentTask node embedded in the store (guarded by a coalescing flag) instead of a Box per dispatch: a Box'd node still sitting in the concurrent queue at VM teardown is unreachable once the VM allocation is freed. The embedded node dies with the VM box, and repeat dispatches coalesce into one wakeup. Resurrected from #33939 (same approach, rebased onto current main, plus the generation bump so queued jobs bail fast). Fixes #33936
f22c03e to
903630e
Compare
|
Superseded by the Worker rewrite in #37075, which landed on main and closed #33936. Transpiler jobs now reach the VM only through the per-VM handle that teardown closes, and teardown waits for or releases in-flight off-thread work before freeing the VM, so the TranspilerJob reads this PR guarded against can no longer land on a freed VM. Verified on current main (165dc9f, debug+ASAN build): this PR's test, "terminate() racing an in-flight dynamic import transpile does not UAF", passes 4 out of 4 runs. Closing. |
Fixes #33936
A
TranspilerJobfor a dynamicimport()runs on the shared work pool and reads the owningVirtualMachineallocation throughoutrun(): the generation check, aptr::readof the wholeTranspiler(whosedefinestable the parser walks ine_dot),test_isolation_enabled,source_mappings, and the job slot itself lives insidevm.transpiler_store(the first 64 slots are an inlineHiveArrayin the VM allocation, so a queued job's WorkPool link is inside that box too).worker.terminate()landing while the job is queued or running freed all of that inWebWorker::shutdown, so the pool thread kept reading freed memory.Repro
A worker that dynamic-imports a large module and is terminated right after scheduling it:
Under ASAN this aborts deterministically once the process outlives the orphaned transpile:
Stock release canary segfaults at
0x30(panic: Segmentation fault at address 0x30, bannerCrashed while printing <mod>.mjs).Fix
Track pool-side jobs with an
in_flightcounter onRuntimeTranspilerStore:TranspilerJob::schedule()(JS thread, before the job is handed to the pool)run()and its dispatch back to the JS thread completeVM teardown joins on the counter before freeing anything
run()reads: inWebWorker::shutdownbefore the step-5 loop teardown, and at the top ofVirtualMachine::destroy()for the destruct-on-exit path. No new job can be scheduled while it blocks, since scheduling only happens on the (now waiting) VM thread.The waiter bumps
generation_numberfirst so jobs still queued on the pool bail at the top ofrun()instead of doing a full parse of a dead VM's import, and polls with a 1 ms futex timeout instead of a decrement-sideFutex::wake: the worker thread frees the VM allocation (including the counter) right after the wait returns, so the pool thread's final access to the VM must be thefetch_subitself; a wake after the decrement could land on freed memory.The dispatch wakeup is a single
ConcurrentTasknode embedded in the store (guarded by a coalescing flag) instead of a Box per dispatch: a Box'd node still sitting in the concurrent queue at VM teardown is unreachable once the VM allocation is freed. The embedded node dies with the VM box, and repeat dispatches coalesce into one wakeup.Verification
New ASAN-gated test in
test/js/web/workers/worker-terminate-lifetime.test.ts: a racer worker is terminated mid-transpile while a second worker's identical import keeps the process alive for the whole exposure window. Fails on unfixed main with the heap-use-after-free above; passes with the fix (the terminated worker's teardown now waits out the in-flight parse, ~4.3 s under debug ASAN).Out of scope (pre-existing, reproduce on stock main)
worker-terminate-lifetime.test.tsdns.lookup case: LSan reports a 4104-byte leak innode_fs_binding::createBindingon current main, unrelated to this diff.object->structure() == this(StructureInlinesLight.h:56) fires during worker terminate(). Confirmed on stock main with tiny modules (so the UAF window never opens). Tracked separately.Rebased from the earlier revision of this PR onto current main, plus the
generation_numberbump so queued jobs bail fast instead of parsing to completion before the wait returns.