Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
139f6c0
One door out of a VM's thread: tickets, and a teardown that waits for…
dylan-conway Aug 14, 2026
38b8dc8
Fix clippy lints in the door module and its assert
dylan-conway Aug 14, 2026
8753771
Merge remote-tracking branch 'origin/main' into claude/vm-thread-safe…
dylan-conway Aug 14, 2026
91685e1
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 14, 2026
83537a9
Gate the Location import to debug builds
dylan-conway Aug 14, 2026
19e5036
Merge remote-tracking branch 'origin/claude/vm-thread-safety-door-993…
dylan-conway Aug 14, 2026
802a1f7
Review: keep the mini-loop guard in ThreadSafe::drop, drop unused tic…
dylan-conway Aug 14, 2026
40100a7
Review: release same-thread concurrent enqueues after close, doc/comm…
dylan-conway Aug 14, 2026
7e8bc9f
Drop the unused erased-ticket layer and uncalled Ticket helpers
dylan-conway Aug 14, 2026
e65d10c
fetch/S3: post progress through the tasklet's ticket in place; move i…
dylan-conway Aug 14, 2026
0b95e1f
Review follow-ups: name shell/cp ticket sites in the debug dump, gate…
dylan-conway Aug 14, 2026
c2aba49
Drop the no-op test_gate::closed hook
dylan-conway Aug 14, 2026
07b4753
Merge remote-tracking branch 'origin/main' into claude/vm-thread-safe…
dylan-conway Aug 14, 2026
f460873
Review follow-ups: hand queued jobs back unrun once the VM is stoppin…
dylan-conway Aug 14, 2026
a37d75e
Cancel a Bun.file/Bun.stdin read or write parked on the io loop when …
dylan-conway Aug 14, 2026
7950a97
s3 download: borrow the in-place ticket only after the &mut callback …
dylan-conway Aug 14, 2026
a386387
Comment fixes: no more references to the removed unsafe impl Sync; sa…
dylan-conway Aug 14, 2026
46a8461
Review round 3: the ticket lives in the Completion; sticky io-wait ca…
dylan-conway Aug 14, 2026
c836a20
io: udata is a pointer on FreeBSD
dylan-conway Aug 14, 2026
ab02eaa
job: restore the header-offset const assert
dylan-conway Aug 14, 2026
e07acfd
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 14, 2026
22d3951
Merge remote-tracking branch 'origin/main' into claude/vm-thread-safe…
dylan-conway Aug 14, 2026
576872f
io: drop the duplicate cancel-udata guard now that main has it
dylan-conway Aug 14, 2026
59148fa
Merge remote-tracking branch 'origin/claude/vm-thread-safety-door-993…
dylan-conway Aug 14, 2026
68b149f
blob: IoParking gets its own file
dylan-conway Aug 14, 2026
eccf810
read_file: Js doc describes the current release path
dylan-conway Aug 14, 2026
01db2c1
Type VirtualMachine::hot_reload as the HotReload enum; the compiled m…
dylan-conway Aug 14, 2026
8827f8f
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 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
8 changes: 4 additions & 4 deletions src/bun_core/env_var.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,10 @@ pub mod feature_flag {
// Run the full VM teardown when the main thread exits (workers always do).
// The CI runner turns it on for LeakSanitizer-validated files on ASAN.
new_feature_flag!(pub BUN_DESTRUCT_VM_ON_EXIT, "BUN_DESTRUCT_VM_ON_EXIT", {});
// Test suite only, builds with debug assertions: a worker VM's handle makes
// cross-thread completions wait for its close, so each producer's "refused"
// release path runs deterministically (bun_jsc::vm_handle::refusal_gate).
new_feature_flag!(pub BUN_DEBUG_TEST_WORKER_REFUSAL_GATE, "BUN_DEBUG_TEST_WORKER_REFUSAL_GATE", {});
// Test suite only, builds with debug assertions: a worker VM holds every
// cross-thread completion until its teardown is waiting, so the "arrived
// during teardown" paths run deterministically (bun_jsc::vm_handle::test_gate).
new_feature_flag!(pub BUN_DEBUG_TEST_WORKER_TEARDOWN_GATE, "BUN_DEBUG_TEST_WORKER_TEARDOWN_GATE", {});

// Disable "nativeDependencies"
new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_NATIVE_DEPENDENCY_LINKER, "BUN_FEATURE_FLAG_DISABLE_NATIVE_DEPENDENCY_LINKER", {});
Expand Down
2 changes: 1 addition & 1 deletion src/bundler/bundle_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1531,7 +1531,7 @@ pub mod bv2_impl {
) {
debug_assert!(self.plugins.is_some());
if let Some(completion) = self.completion {
// From Bun.build — the completion posts it to its VM (`loop_handle.post_task` via the vtable).
// From Bun.build — the completion posts it to its VM (through its ticket, via the vtable).
completion.enqueue_task_concurrent(task);
return;
}
Expand Down
48 changes: 17 additions & 31 deletions src/event_loop/AnyEventLoop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ impl Default for AnyEventLoop {
}

impl AnyEventLoop {
/// Owning thread: the poster other threads use to deliver JS-loop tasks
/// to this loop's VM; `None` for a mini loop.
/// Owning thread: the weak poster other threads use to deliver JS-loop
/// tasks to this loop's VM; `None` for a mini loop.
pub fn js_poster(&self) -> Option<JsPoster> {
match self {
AnyEventLoop::Js { owner } => Some(owner.js_poster()),
Expand Down Expand Up @@ -433,9 +433,9 @@ impl EventLoopHandle {
EnteredEventLoop(self)
}

/// Owning thread: the poster other threads use to deliver JS-loop tasks to
/// this handle's VM; `None` for a mini loop (post to it directly — it is
/// owned by, and outlives the work of, its thread).
/// Owning thread: the weak poster other threads use to deliver JS-loop
/// tasks to this handle's VM; `None` for a mini loop (post to it directly —
/// it is owned by, and outlives the work of, its thread).
pub fn js_poster(&self) -> Option<JsPoster> {
match self {
EventLoopHandle::Js { owner } => Some(owner.js_poster()),
Expand Down Expand Up @@ -525,15 +525,16 @@ impl EventLoopHandle {

// ─────────────────────────── JsPoster ──────────────────────────────────────
//
// How code below `bun_jsc` (spawn's waiter thread, the bundler's JS-loop hops,
// shell/fs work that may serve a JS VM) posts a `ConcurrentTask` to a JS VM
// from another thread. It is an erased `bun_jsc::VmHandle` clone: `bun_jsc`
// fills the vtable; holders just call `post`. The VM's teardown closes the
// underlying handle, after which `post` refuses (returns the task) and the
// caller releases it on its own thread. Valid for as long as it is held.

/// Result of posting a task to a JS loop from another thread: it was queued, or
/// the loop's VM is gone and the caller has the task back to release on this
// How code below `bun_jsc` reaches a JS VM from another thread: an erased,
// *uncounted* `bun_jsc::VmHandle` (`bun_jsc` fills the vtable) — what something
// that merely refers to a VM holds (spawn's process-wide waiter thread, a
// bundle owned by a JS loop). Its `post` is deliver-or-refuse: once the VM has
// closed, the task comes back and the caller releases it — its own payload —
// on its own thread. Work that a VM must *wait* for holds a `bun_jsc::Ticket`
// instead (a `Bun.build`'s completion task carries one for the bundle thread).

/// Result of a weak post to a JS loop from another thread: it was queued, or
/// the loop's VM is closed and the caller has the task back to release on this
/// thread.
#[must_use = "a refused task must be released by its producer"]
pub enum Posted {
Expand All @@ -543,9 +544,6 @@ pub enum Posted {

pub struct JsPosterVTable {
pub post: unsafe fn(data: *const (), task: NonNull<ConcurrentTask>) -> Posted,
/// `VmHandle::embedded_work_scheduled` / `_finished` (see there).
pub embedded_work_scheduled: unsafe fn(data: *const ()),
pub embedded_work_finished: unsafe fn(data: *const ()),
pub clone: unsafe fn(data: *const ()) -> *const (),
pub drop: unsafe fn(data: *const ()),
}
Expand All @@ -564,31 +562,19 @@ unsafe impl Sync for JsPoster {}
impl JsPoster {
/// # Safety
/// `data`/`vtable` come from one of `bun_jsc::vm_handle`'s `to_js_poster`
/// implementations (`VmHandle` / `LoopHandle` / the isolated poster).
/// implementations (`VmHandle` / the isolated poster).
#[inline]
pub unsafe fn from_raw(data: *const (), vtable: &'static JsPosterVTable) -> Self {
Self { data, vtable }
}

/// Queue `task` on the VM this poster was created for and wake it, or hand
/// it back if the VM has been torn down.
/// it back if the VM has closed.
#[inline]
pub fn post(&self, task: NonNull<ConcurrentTask>) -> Posted {
// SAFETY: vtable contract.
unsafe { (self.vtable.post)(self.data, task) }
}

#[inline]
/// Count work whose storage the VM (indirectly) owns; it waits for the
/// matching `embedded_work_finished` before closing. See `VmHandle`.
pub fn embedded_work_scheduled(&self) {
// SAFETY: vtable contract.
unsafe { (self.vtable.embedded_work_scheduled)(self.data) }
}
pub fn embedded_work_finished(&self) {
// SAFETY: vtable contract.
unsafe { (self.vtable.embedded_work_finished)(self.data) }
}
}

impl Clone for JsPoster {
Expand Down
47 changes: 30 additions & 17 deletions src/event_loop/ConcurrentTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,12 @@ pub struct Task {
/// freed when it will never run. Implement on every type that can be
/// enqueued; the impl lives in whatever crate owns the type.
///
/// A queued task ends one of three ways: it runs (`bun_runtime::dispatch::
/// run_task`); it is refused at post because its VM already closed (the
/// poster frees it — `Postable::release_refused` / the `Posted::Refused` arm);
/// or it was queued in time but its VM stops before running it —
/// A queued task ends one of two ways: it runs (`bun_runtime::dispatch::
/// run_task`), or its VM stops before running it —
/// [`release_unrun`](Self::release_unrun), required here so no type can be
/// queued without having decided it.
/// queued without having decided it. (A *weak* poster — `JsPoster` — can also
/// get its task back unqueued once the VM has closed; that task never entered
/// a queue and is the poster's own to free: [`ConcurrentTask::release_refused`].)
///
/// Re-exported from `bun_jsc` for ergonomics, but defined here (lowest tier on
/// the hot-dispatch list, see PORTING.md §Dispatch) so that
Expand Down Expand Up @@ -312,23 +312,36 @@ impl ConcurrentTask {
self
}

/// A poster got `task` back because the target VM is gone: free it if it
/// is a heap task (`create*`); an intrusive one belongs to its container.
/// Consuming thread: unwrap the payload, freeing the carrier if it was
/// heap-allocated (`create*`); an intrusive carrier stays with its container.
///
/// # Safety
/// `task` was just refused and is not queued anywhere.
pub unsafe fn release_refused(task: core::ptr::NonNull<ConcurrentTask>) {
/// `this` came off a queue (or was never queued) and is not used afterwards.
pub unsafe fn into_task(this: core::ptr::NonNull<ConcurrentTask>) -> Task {
// SAFETY: fn contract.
unsafe {
// A callback task (`from_callback`, `ManagedTask::new*`) owns a
// heap `ManagedTask` behind `task.ptr` as well.
let inner = task.as_ref().task;
if inner.tag == crate::task_tag::ManagedTask {
crate::ManagedTask::ManagedTask::release(inner.ptr.cast());
}
if task.as_ref().auto_delete() {
drop(bun_core::heap::take(task.as_ptr()));
let (task, auto_delete) = (this.as_ref().task, this.as_ref().auto_delete());
if auto_delete {
drop(bun_core::heap::take(this.as_ptr()));
}
task
}
}

/// A weak poster got `task` back because the target VM has closed: free
/// it if it is a heap task (`create*`); an intrusive one belongs to its
/// container.
///
/// # Safety
/// `task` was just refused and is not queued anywhere.
pub unsafe fn release_refused(task: core::ptr::NonNull<ConcurrentTask>) {
// SAFETY: fn contract.
let inner = unsafe { Self::into_task(task) };
// A callback task (`from_callback`, `ManagedTask::new*`) owns a heap
// `ManagedTask` behind `task.ptr` as well.
if inner.tag == crate::task_tag::ManagedTask {
// SAFETY: as above; refused ⇒ ours.
unsafe { crate::ManagedTask::ManagedTask::release(inner.ptr.cast()) };
}
}

Expand Down
1 change: 1 addition & 0 deletions src/event_loop/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ bun_dispatch::link_interface! {
fn enter();
fn exit();
fn enqueue_task(task: Task);
fn enqueue_task_after_yield(task: Task);
fn js_poster() -> any_event_loop::JsPoster;
fn env() -> *mut bun_dotenv::Loader;
fn top_level_dir() -> *const [u8];
Expand Down
12 changes: 7 additions & 5 deletions src/jsc/AsyncModule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,13 @@ pub struct Queue {

/// What the resolver's `WakeHandler` carries as its opaque context: the
/// module queue (for the JS-thread dependency-error callback) and the VM's
/// handle (for wake-ups from install / HTTP threads). Allocated once per VM at
/// registration and kept for the VM's lifetime.
/// weak handle (for wake-ups from the process-wide install / HTTP threads,
/// which outlive any one VM). Allocated once per VM at registration and kept
/// for the VM's lifetime.
pub struct WakeContext {
pub queue: *mut Queue,
pub loop_handle: crate::LoopHandle,
pub handle: crate::VmHandle,
pub kind: crate::LoopKind,
}

impl Queue {
Expand Down Expand Up @@ -382,8 +384,8 @@ impl Queue {
// SAFETY: `ctx` is the leaked `WakeContext` registered with this handler.
let ctx = unsafe { &*ctx.cast::<WakeContext>() };
let task = ConcurrentTaskItem::create_from(ctx.queue);
if let crate::vm_handle::Posted::Refused(task) = ctx.loop_handle.post_task(task) {
// VM torn down: nobody is waiting on these modules any more.
if let crate::vm_handle::Posted::Refused(task) = ctx.handle.post(ctx.kind, task) {
// That VM has closed: nobody is waiting on these modules any more.
// SAFETY: refused ⇒ we own the task box.
unsafe { drop(bun_core::heap::take(task.as_ptr())) };
}
Expand Down
50 changes: 19 additions & 31 deletions src/jsc/CppTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@ use crate::{JSGlobalObject, JsResult};
use bun_event_loop::{TaskTag, Taskable, task_tag};
use bun_threading::work_pool::{Task as WorkPoolTask, WorkPool};

#[allow(improper_ctypes)] // `Shared` is opaque to C++ (`BunVmHandleRef`)
unsafe extern "C" {
fn Bun__EventLoopTaskNoContext__performTask(task: *mut EventLoopTaskNoContext);
safe fn Bun__EventLoopTaskNoContext__vmHandle(
task: &EventLoopTaskNoContext,
) -> *const crate::vm_handle::Shared;
}

bun_opaque::opaque_ffi! {
Expand Down Expand Up @@ -53,54 +49,46 @@ impl EventLoopTaskNoContext {
// SAFETY: caller guarantees `this` is a valid C++ EventLoopTaskNoContext; performTask consumes/frees it.
unsafe { Bun__EventLoopTaskNoContext__performTask(this) }
}

/// The handle of the VM this task was created in (a reference the C++
/// task holds for its lifetime).
pub(crate) fn vm_handle(&self) -> crate::vm_handle::BorrowedRef {
// SAFETY: C++ stores a `BunVmHandleRef` from `Bun__VmHandle__retainRef`
// for the task's whole lifetime.
unsafe { crate::VmHandle::borrow_ref(Bun__EventLoopTaskNoContext__vmHandle(self)) }
}
}

/// A task created from C++ code that runs inside the workpool, usually via ScriptExecutionContext.
/// A task created from C++ code that runs inside the workpool (WebCrypto's
/// `PhonyWorkQueue`). Holds the creating VM's ticket: the C++ closure captures
/// context-affine objects and posts its result back by context id.
#[repr(C)]
pub struct ConcurrentCppTask {
pub(crate) cpp_task: *mut EventLoopTaskNoContext,
pub(crate) ticket: crate::Ticket,
pub(crate) workpool_task: WorkPoolTask,
}

bun_threading::owned_task!(ConcurrentCppTask, workpool_task);

impl ConcurrentCppTask {
#[allow(clippy::boxed_local)] // `owned_task!`'s required signature
fn run_owned(self: Box<Self>) {
// Extract all the info we need from `self` and `cpp_task` before we call functions that
// free them.
let cpp_task = self.cpp_task;
// `EventLoopTaskNoContext` is an `opaque_ffi!` ZST handle; `opaque_ref`
// is the centralised non-null deref proof. Valid until `run` consumes it.
// Clone before `run` consumes (and frees) the C++ task that holds the reference.
let handle: crate::VmHandle = EventLoopTaskNoContext::opaque_ref(cpp_task)
.vm_handle()
.clone();
drop(self);
let ConcurrentCppTask {
cpp_task, ticket, ..
} = *self;
// SAFETY: `cpp_task` is the valid C++ handle stored by `ConcurrentCppTask__createAndRun`;
// `opaque_ref` above proved it non-null and it has not yet been freed — `run` consumes it here.
// `run` consumes it here.
unsafe { EventLoopTaskNoContext::run(cpp_task) };
handle.unref_keep_alive(crate::LoopKind::Regular);
ticket.unref_keep_alive();
}
}

/// JS thread (`PhonyWorkQueue::dispatch`).
#[unsafe(no_mangle)]
extern "C" fn ConcurrentCppTask__createAndRun(cpp_task: *mut EventLoopTaskNoContext) {
extern "C" fn ConcurrentCppTask__createAndRun(
global: &JSGlobalObject,
cpp_task: *mut EventLoopTaskNoContext,
) {
crate::mark_binding!();
// `EventLoopTaskNoContext` is an `opaque_ffi!` ZST handle; `opaque_ref` is
// the centralised non-null deref proof. C++ just handed it over.
EventLoopTaskNoContext::opaque_ref(cpp_task)
.vm_handle()
.ref_keep_alive(crate::LoopKind::Regular);
let vm = global.bun_vm();
vm.event_loop_shared().ref_keep_alive();
let ticket = vm.ticket();
WorkPool::schedule_new(ConcurrentCppTask {
cpp_task,
ticket,
workpool_task: WorkPoolTask::default(),
});
}
Loading