Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f2311f4
Don't enqueue to a terminated worker's freed event loop from other th…
robobun Jun 10, 2026
c618e4c
Allow not_unsafe_ptr_arg_deref on the checked enqueue entry points
robobun Jun 10, 2026
007f64b
Address review: dangling-reference fields, failure-path cleanup, visi…
robobun Jun 10, 2026
64bafc1
Don't deinit a dead worker's FetchTasklet against its freed JSC heap
robobun Jun 10, 2026
575acf4
Make cross-thread VM field reads atomic and shrink dead-VM tasklet pa…
robobun Jun 10, 2026
3ca7b27
Publish MAIN_THREAD_VM only once the main VM is fully initialized
robobun Jun 10, 2026
0cf6132
Carry a generation token with cross-thread VM handles (#32082)
robobun Jun 12, 2026
4bbd5de
Merge branch 'main' into farm/7117067b/worker-terminate-concurrent-qu…
Jarred-Sumner Jun 12, 2026
c417173
Make VmHandle the only cross-thread VM identity, including C++ captures
robobun Jun 12, 2026
950d3c9
Remove a no-op borrow of the fetch tasklet's VM handle
robobun Jun 12, 2026
26fe733
Load the positive-delivery worker from a file instead of a data: URL
robobun Jun 12, 2026
6f4c56a
Sync stale VM-lifetime comments with the handle semantics
robobun Jun 12, 2026
a300ceb
Fix two more stale lifetime comments and ungroup the parked-tasklet s…
robobun Jun 12, 2026
26aa62e
ci: retrigger
robobun Jun 12, 2026
c34de46
Merge main into worker-terminate-concurrent-queue-uaf
robobun Jul 3, 2026
7ffec34
Merge remote-tracking branch 'origin/main' into farm/7117067b/worker-…
robobun Jul 10, 2026
1192b82
ci: re-run checks
robobun Jul 10, 2026
1e6d30a
Merge remote-tracking branch 'origin/main' into farm/7117067b/worker-…
robobun Jul 10, 2026
d1c2a20
Correct stale doc claims about MAIN_THREAD_VM publish, event_loop tar…
robobun Jul 10, 2026
7f78581
Fix three more doc comments contradicted by the generation capture
robobun Jul 10, 2026
76cb8a3
Qualify the link_impl_JsEventLoop header for the cross-thread enqueue…
robobun Jul 10, 2026
d680781
Merge remote-tracking branch 'origin/main' into farm/7117067b/worker-…
robobun Jul 11, 2026
99fb435
Merge remote-tracking branch 'origin/main' into farm/7117067b/worker-…
robobun Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/jsc/AsyncModule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,8 +372,11 @@ impl Queue {
// `from_field_ptr!` is sound. S017 does not apply: that rule forbids
// widening from a `&mut self`-derived pointer, but `ctx` is a raw
// `*mut` carried from the original allocation.
let vm = unsafe { &mut *bun_core::from_field_ptr!(VirtualMachine, modules, queue) };
vm.enqueue_task_concurrent(task);
let vm: *mut VirtualMachine =
unsafe { bun_core::from_field_ptr!(VirtualMachine, modules, queue) };
// Checked: the wake can fire after a worker VM that owned `queue` was
// freed by terminate(); the pointer arithmetic above performs no read.
let _ = VirtualMachine::try_enqueue_task_concurrent(vm, task);
}

pub fn on_poll(&mut self) {
Expand Down
4 changes: 3 additions & 1 deletion src/jsc/ConcurrentPromiseTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ impl<'a, Context: ConcurrentPromiseTaskContext> ConcurrentPromiseTask<'a, Contex
);
// `task` is the live `concurrent_task` field of the heap-allocated
// job; the queue takes ownership of its intrusive `next` link.
event_loop.enqueue_task_concurrent(task);
// `event_loop` may point into a worker VM freed by terminate() while
// the pool task ran — checked enqueue only.
let _ = EventLoop::try_enqueue_task_concurrent(event_loop.as_ptr(), task);
}

/// Frees the heap allocation backing this task.
Expand Down
7 changes: 5 additions & 2 deletions src/jsc/CppTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ impl ConcurrentCppTask {
// `opaque_ref` above proved it non-null and it has not yet been freed — `run` consumes it here.
unsafe { EventLoopTaskNoContext::run(cpp_task) };
if let Some(vm) = maybe_vm {
vm.event_loop_shared().unref_concurrently();
// Checked: runs on the work-pool thread; the creating VM may be a
// worker freed by terminate() while this task ran.
VirtualMachine::try_unref_concurrently(vm.as_ptr());
}
}
}
Expand All @@ -90,7 +92,8 @@ pub(crate) extern "C" fn ConcurrentCppTask__createAndRun(cpp_task: *mut EventLoo
// `EventLoopTaskNoContext` is an `opaque_ffi!` ZST handle; `opaque_ref` is
// the centralised non-null deref proof. C++ just handed it over.
if let Some(vm) = EventLoopTaskNoContext::opaque_ref(cpp_task).get_vm() {
vm.event_loop_shared().ref_concurrently();
// Checked for symmetry with the pool-thread unref in `run_owned`.
VirtualMachine::try_ref_concurrently(vm.as_ptr());
}
WorkPool::schedule_new(ConcurrentCppTask {
cpp_task,
Expand Down
23 changes: 11 additions & 12 deletions src/jsc/JSCScheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use core::ffi::c_int;

use bun_event_loop::{ConcurrentTask::ConcurrentTask, TaskTag, Taskable, task_tag};

use crate::event_loop::{EventLoop, JsTerminated};
use crate::event_loop::JsTerminated;
use crate::virtual_machine::VirtualMachine;

bun_opaque::opaque_ffi! {
Expand Down Expand Up @@ -38,30 +38,29 @@ impl JSCDeferredWorkTask {

#[unsafe(no_mangle)]
pub(crate) extern "C" fn Bun__eventLoop__incrementRefConcurrently(
jsc_vm: &VirtualMachine,
jsc_vm: *mut VirtualMachine,
delta: c_int,
) {
crate::mark_binding!();
// C++ passes a non-null live `VirtualMachine*`; ABI-compatible with `&T`.
// `event_loop_shared()` is the safe accessor over the VM-owned EventLoop.
let event_loop: &EventLoop = jsc_vm.event_loop_shared();
// Checked: called from JSC helper threads, which can outlive a
// terminated worker's VM (the counter of a freed loop needs no balancing).
if delta > 0 {
event_loop.ref_concurrently();
VirtualMachine::try_ref_concurrently(jsc_vm);
} else {
event_loop.unref_concurrently();
VirtualMachine::try_unref_concurrently(jsc_vm);
}
}

#[unsafe(no_mangle)]
pub(crate) extern "C" fn Bun__queueJSCDeferredWorkTaskConcurrently(
jsc_vm: &VirtualMachine,
jsc_vm: *mut VirtualMachine,
task: *mut JSCDeferredWorkTask,
) {
crate::mark_binding!();
// C++ passes a non-null live `VirtualMachine*`; ABI-compatible with `&T`.
let loop_: &EventLoop = jsc_vm.event_loop_shared();
// `create_from` heap-allocates with the auto-delete bit set.
loop_.enqueue_task_concurrent(ConcurrentTask::create_from(task));
// Checked: called from JSC concurrent threads, which can outlive a
// terminated worker's VM. `create_from` heap-allocates with the
// auto-delete bit set (freed by the checked enqueue when the VM is gone).
let _ = VirtualMachine::try_enqueue_task_concurrent(jsc_vm, ConcurrentTask::create_from(task));
}

/// # Safety
Expand Down
24 changes: 15 additions & 9 deletions src/jsc/RuntimeTranspilerStore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,16 +515,22 @@ impl TranspilerJob {

pub(crate) fn dispatch_to_main_thread(&mut self) {
let vm = self.vm;
// SAFETY: vm outlives the job (BACKREF — VM owns the store).
let transpiler_store: *mut RuntimeTranspilerStore =
unsafe { ptr::addr_of_mut!((*vm).transpiler_store) };
let job = NonNull::from(&mut *self);
// SAFETY: queue is concurrent-safe (UnboundedQueue uses atomics).
unsafe { (*transpiler_store).queue.push(job) };
// Another thread may free `self` at any time after .push, so we cannot use it any more.
// SAFETY: vm outlives the job; event_loop() returns the live self-pointer.
unsafe { &*(*vm).event_loop() }
.enqueue_task_concurrent(ConcurrentTask::create_from(transpiler_store));
// Both the store's queue and the event loop live inside the VM
// allocation, which may be a worker VM freed by terminate() while
// this job ran on the pool — touch them only inside `with_live_vm`
// (the registry lock holds off the free). When the VM is gone the
// job is simply dropped on the floor; nothing will ever drain it.
let _ = crate::VirtualMachineRef::with_live_vm(vm, |vm| {
let transpiler_store: *mut RuntimeTranspilerStore =
ptr::addr_of!(vm.transpiler_store).cast_mut();
// SAFETY: queue is concurrent-safe (UnboundedQueue uses atomics).
unsafe { (*transpiler_store).queue.push(job) };
// Another thread may free `*job` at any time after .push, so we
// cannot use it any more.
vm.event_loop_shared()
.enqueue_task_concurrent(ConcurrentTask::create_from(transpiler_store));
Comment thread
robobun marked this conversation as resolved.
});
}

pub(crate) fn run_from_js_thread(&mut self) -> JsResult<()> {
Expand Down
217 changes: 217 additions & 0 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,120 @@ impl VMHolder {
}
}

/// Process-global registry of `(VirtualMachine, EventLoop)` addresses that
/// cross-thread producers may still enqueue to.
///
/// Worker `VirtualMachine`s (and the `EventLoop`s embedded in them) are freed
/// by `WebWorker::shutdown()` while producers on other threads — the HTTP
/// client thread (fetch/S3 completions), the work pool (fs/crypto/zlib/napi
/// completions), watcher threads, napi addon threads — still hold raw
/// pointers captured when the work was scheduled. A push after the free
/// corrupts reused heap memory; the corrupted task queue then surfaces as
/// "invalid enum value" panics in `tickQueueWithCount` on whichever live
/// worker inherited the memory.
///
/// This is the Rust-side analogue of the fence the C++ `postTaskTo` path
/// already has (`allScriptExecutionContextsMap` + its lock, see
/// `ScriptExecutionContext.cpp`): teardown removes the VM from the registry
/// under the same lock producers take to enqueue, so a producer either
/// observes the VM live (and the teardown path then waits for the lock before
/// freeing) or drops the task.
///
/// Addresses are stored as `usize` — the registry never dereferences them.
/// The main-thread VM is registered but never unregistered (its allocation is
Comment thread
robobun marked this conversation as resolved.
/// static-rooted and never freed), which enables the lock-free fast path in
/// the checked entry points below.
///
/// Known residual (pre-existing, strictly narrower than the bug this fixes):
/// liveness is keyed by address only, so if a new VM is allocated at a dead
/// VM's address, a stale producer that captured the old pointer passes the
/// check and its task is delivered to the new VM instead of being dropped.
/// That mis-delivery was already possible before this registry existed (the
/// stale push landed at the same reused address, plus every freed-memory
/// interleaving that is now closed). Eliminating it requires producers to
/// carry a schedule-time generation token alongside the pointer — a
/// follow-up that touches every producer struct.
///
/// Lock ordering: this lock is a leaf. The critical sections only touch the
/// target's MPSC queue (wait-free push) and `wakeup()` (a syscall); they take
/// no other locks.
pub(crate) mod live_vm_registry {
use super::VirtualMachine;
use crate::event_loop::EventLoop;
use bun_threading::Guarded;

#[derive(Copy, Clone, Eq, PartialEq)]
pub(crate) struct Entry {
pub(crate) vm: usize,
pub(crate) loop_: usize,
}

pub(crate) static REGISTRY: Guarded<Vec<Entry>> = Guarded::new(Vec::new());

/// Register `vm` and both of its embedded event loops. Called once from
/// `VirtualMachine::init()` after `regular_event_loop`/`macro_event_loop`
/// are initialised.
pub(crate) fn register_vm(vm: *mut VirtualMachine) {
// SAFETY: `vm` is the freshly initialised allocation; `addr_of!` only
// projects field addresses, no reads.
let (regular, macro_) = unsafe {
(
core::ptr::addr_of!((*vm).regular_event_loop),
core::ptr::addr_of!((*vm).macro_event_loop),
)
};
let mut reg = REGISTRY.lock();
reg.push(Entry {
vm: vm as usize,
loop_: regular as usize,
});
reg.push(Entry {
vm: vm as usize,
loop_: macro_ as usize,
});
}

/// Remove every entry for `vm`. Called from `WebWorker::shutdown()` before
/// anything the VM owns is freed; once this returns, no producer can be
/// inside a checked enqueue targeting `vm` (they would have to re-acquire
/// the lock and re-check).
pub(crate) fn unregister_vm(vm: *mut VirtualMachine) {
REGISTRY.lock().retain(|e| e.vm != vm as usize);
}

/// Register a loop that lives outside the VM allocation (the boxed
/// spawnSync event loop). Removed with `unregister_loop` when the box is
/// freed.
pub(crate) fn register_extra_loop(vm: *mut VirtualMachine, loop_: *mut EventLoop) {
REGISTRY.lock().push(Entry {
vm: vm as usize,
loop_: loop_ as usize,
});
}

pub(crate) fn unregister_loop(loop_: *mut EventLoop) {
REGISTRY.lock().retain(|e| e.loop_ != loop_ as usize);
}

/// `true` iff `loop_` is one of the immortal main-thread VM's embedded
/// loops. Address arithmetic only; the main VM allocation is never freed.
pub(crate) fn is_main_vm_loop(loop_: *mut EventLoop) -> bool {
let main = super::MAIN_THREAD_VM.load(core::sync::atomic::Ordering::Acquire);
if main.is_null() {
return false;
}
// SAFETY: `main` is the live, never-freed main-thread VM; `addr_of!`
// only projects field addresses, no reads.
let (regular, macro_) = unsafe {
(
core::ptr::addr_of!((*main).regular_event_loop),
core::ptr::addr_of!((*main).macro_event_loop),
)
};
core::ptr::eq(loop_, regular.cast_mut()) || core::ptr::eq(loop_, macro_.cast_mut())
}
}

#[thread_local]
pub static IS_BUNDLER_THREAD_FOR_BYTECODE_CACHE: Cell<bool> = Cell::new(false);
#[thread_local]
Expand Down Expand Up @@ -2114,6 +2228,11 @@ impl VirtualMachine {
let _ = (*regular).tasks.ensure_unused_capacity(64);
addr_of_mut!((*vm).event_loop).write(regular);

// Make this VM reachable for checked cross-thread enqueues.
// Worker VMs are unregistered in `WebWorker::shutdown()` before
// the allocation is freed; the main VM stays registered forever.
live_vm_registry::register_vm(vm);

// `source_mappings.map` is a sibling-field backref onto
// `saved_source_map_table`.
addr_of_mut!((*vm).saved_source_map_table)
Expand Down Expand Up @@ -3571,6 +3690,104 @@ impl VirtualMachine {
self.event_loop_mut().enqueue_task_concurrent(task);
}

/// Run `f` against `vm` only if it is still alive, tolerating `vm` having
/// been freed (terminated worker). Returns `None` without touching `*vm`
/// when it is gone.
///
/// For the immortal main-thread VM this is lock-free; for every other VM,
/// `f` runs under the [`live_vm_registry`] lock, which `unregister_vm`
/// (called before any free) also takes — so the VM cannot be freed while
/// `f` runs. `f` must therefore be short and lock-free: pushing to the
/// MPSC queue, `wakeup()`, reading a flag. The `&VirtualMachine` handed to
/// `f` may be on a non-JS thread — `f` must restrict itself to the
/// documented thread-safe subset (the same contract as the `Sync` impl),
/// which is why this helper is crate-private rather than `pub`.
// Deliberately takes `*mut` and is NOT `unsafe`: accepting a possibly
// dangling pointer is the function's contract, and no deref happens until
// the registry proves the pointee live (and holds off its free).
#[allow(clippy::not_unsafe_ptr_arg_deref)]
pub(crate) fn with_live_vm<R>(
vm: *mut VirtualMachine,
f: impl FnOnce(&VirtualMachine) -> R,
) -> Option<R> {
if vm.is_null() {
return None;
}
// Fast path: the main-thread VM is allocated once and never freed, so
// an address match proves liveness without the lock.
if core::ptr::eq(
vm,
MAIN_THREAD_VM.load(core::sync::atomic::Ordering::Acquire),
) {
// SAFETY: main-thread VM, never freed.
return Some(f(unsafe { &*vm }));
}
let reg = live_vm_registry::REGISTRY.lock();
if !reg.iter().any(|e| e.vm == vm as usize) {
return None;
}
// SAFETY: `vm` is registered-live, and `unregister_vm` (which
// happens-before any free of the VM) takes the same lock we hold, so
// the VM cannot be freed while `f` runs.
Some(f(unsafe { &*vm }))
}

/// Cross-thread enqueue that tolerates `vm` having been freed (terminated
/// worker). Producers that captured `vm` at schedule time and deliver a
/// completion from another thread (HTTP client thread, work pool, watcher
/// threads, napi addon threads) must use this instead of dereferencing
/// `vm` directly — see [`live_vm_registry`].
///
/// Returns `false` when the VM is gone: the task was not queued, and
/// `task`'s node was freed if it was `auto_delete` (the payload is
/// intentionally not touched — equivalent to a task left undrained in a
/// terminated worker's queue, which is the pre-existing behavior for
/// tasks that lost this race by a few milliseconds).
pub fn try_enqueue_task_concurrent(
vm: *mut VirtualMachine,
task: core::ptr::NonNull<crate::event_loop::ConcurrentTaskItem>,
) -> bool {
match Self::with_live_vm(vm, |vm| {
vm.event_loop_shared().enqueue_task_concurrent(task);
}) {
Some(()) => true,
None => {
crate::event_loop::discard_unqueued_concurrent_task(task);
false
}
}
}

/// Like [`VirtualMachine::is_shutting_down`], but callable with a pointer
/// that may already be freed (terminated worker): a freed VM reports
/// `true`. For HTTP-thread / work-pool completion paths that branch on
/// shutdown before touching VM-owned state.
pub fn is_shutting_down_or_freed(vm: *mut VirtualMachine) -> bool {
Self::live_shutting_down_state(vm).unwrap_or(true)
}

/// Tri-state variant of [`Self::is_shutting_down_or_freed`] for callers
/// that must distinguish a freed VM from a live-but-exiting one: `None`
/// when `vm` is gone (terminated worker), otherwise
/// `Some(is_shutting_down)`. Because `WebWorker::shutdown()` unregisters
/// the VM *before* setting `is_shutting_down`, `Some(true)` can only be
/// observed for VMs that are never freed (the main VM during
/// `global_exit`).
pub fn live_shutting_down_state(vm: *mut VirtualMachine) -> Option<bool> {
Self::with_live_vm(vm, |vm| vm.is_shutting_down())
}

/// `ref_concurrently`/`unref_concurrently` variants of
/// [`Self::try_enqueue_task_concurrent`]: no-ops when the VM is gone (a
/// freed loop has no liveness counter left to balance).
pub fn try_ref_concurrently(vm: *mut VirtualMachine) {
let _ = Self::with_live_vm(vm, |vm| vm.event_loop_shared().ref_concurrently());
}

pub fn try_unref_concurrently(vm: *mut VirtualMachine) {
let _ = Self::with_live_vm(vm, |vm| vm.event_loop_shared().unref_concurrently());
}

/// `cond` is `&Cell<bool>` (not `&mut bool`): the re-entrant
/// `tick()/auto_tick()` calls run JS that flips the flag through an
/// independently-captured handle, so the read must not be `noalias`.
Expand Down
6 changes: 4 additions & 2 deletions src/jsc/WorkTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ impl<Context: WorkTaskContext> WorkTask<Context> {
.from(this_ptr, AutoDeinit::ManualDeinit),
);
// `task` is the inline `concurrent_task` field of the live
// heap-allocated `*this`; `event_loop` is the JS-thread loop stored at init.
event_loop.enqueue_task_concurrent(task);
// heap-allocated `*this`; `event_loop` was stored at init and may
// point into a worker VM freed by terminate() while the pool task
// ran — checked enqueue only.
let _ = EventLoop::try_enqueue_task_concurrent(event_loop.as_ptr(), task);
}
}
Loading
Loading