diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index ec416bb3e5bd..7df70af56ea2 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -208,10 +208,10 @@ int us_loop_close_all_groups(struct us_loop_t *loop) { int any = 0; while (g) { struct us_socket_group_t *next = g->next; - /* Only connecting/connected sockets are stranded — listen sockets are - * 1:1 owned by a Zig Listener / uWS App that holds a raw pointer and - * closes them in finalize(). Closing them here turns that into a UAF - * after drainClosedSockets(). */ + /* Only connecting/connected sockets are stranded here. Listen sockets are + * 1:1 owned by a Listener / uWS App that holds a raw pointer to them; the + * runtime's stop phase has already stopped those owners before this sweep, + * and closing a listen socket from under one that was not would be a UAF. */ if (g->head_sockets || g->head_connecting_sockets || g->low_prio_count) { us_socket_group_close_all_ex(g, /* also_listeners */ 0); any = 1; diff --git a/packages/bun-uws/src/Loop.h b/packages/bun-uws/src/Loop.h index d8d8003c74b5..b62bddf47469 100644 --- a/packages/bun-uws/src/Loop.h +++ b/packages/bun-uws/src/Loop.h @@ -126,8 +126,11 @@ struct Loop { return getLazyLoop().loop; } - static void clearLoopAtThreadExit() { - if (getLazyLoop().cleanMe) { + /* A thread that ran a loop is exiting: free this thread's loop whether uSockets created the + * native loop (cleanMe) or was handed one (Windows: the thread's libuv loop, which the caller + * closes afterwards; us_loop_free leaves a borrowed native loop alone). */ + static void freeLoopAtThreadExit() { + if (getLazyLoop().loop) { getLazyLoop().loop->free(); } } diff --git a/scripts/build/codegen.ts b/scripts/build/codegen.ts index e848841fc71c..a31d3c3cfa40 100644 --- a/scripts/build/codegen.ts +++ b/scripts/build/codegen.ts @@ -623,13 +623,12 @@ function emitHostExports({ n, cfg, sources, o, dirStamp }: Ctx): void { // the two crates so unrelated edits (e.g. src/bundler) don't re-run the // scrape. restat=1 + writeIfNotChanged means a no-marker-change edit // produces identical output and the cargo step is pruned. - const rsInputs = sources.rust.filter( - p => - p.endsWith(".rs") && - (p.includes(`${cfg.cwd}/src/runtime/`.replace(/\//g, "/")) || - p.includes(`${cfg.cwd}/src/jsc/`.replace(/\//g, "/"))) && - !p.endsWith("generated_host_exports.rs"), - ); + const slashed = (p: string) => p.replace(/\\/g, "/"); + const scrapeDirs = [slashed(`${cfg.cwd}/src/runtime/`), slashed(`${cfg.cwd}/src/jsc/`)]; + const rsInputs = sources.rust.filter(p => { + const q = slashed(p); + return q.endsWith(".rs") && scrapeDirs.some(d => q.includes(d)) && !q.endsWith("generated_host_exports.rs"); + }); n.build({ outputs: [output], diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index f3fd88db6d98..b83fd45a6ad5 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "ddea71318fec9b923465c7c45ded8fa713ca3251"; +export const WEBKIT_VERSION = "171babe26c3b330ac0263d1bed3550571908c838"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 728b3799ef0b..9ca6d375d233 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -203,7 +203,13 @@ pub mod feature_flag { new_feature_flag!(pub BUN_ASSUME_PERFECT_INCREMENTAL, "BUN_ASSUME_PERFECT_INCREMENTAL", { default: None }); new_feature_flag!(pub BUN_BE_BUN, "BUN_BE_BUN", {}); new_feature_flag!(pub BUN_DEBUG_NO_DUMP, "BUN_DEBUG_NO_DUMP", {}); + // 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", {}); // Disable "nativeDependencies" new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_NATIVE_DEPENDENCY_LINKER, "BUN_FEATURE_FLAG_DISABLE_NATIVE_DEPENDENCY_LINKER", {}); diff --git a/src/bundler/BundleThread.rs b/src/bundler/BundleThread.rs index 91e2b061422d..c5a405b1549a 100644 --- a/src/bundler/BundleThread.rs +++ b/src/bundler/BundleThread.rs @@ -65,6 +65,10 @@ pub trait CompletionStruct: Node + Send + 'static { transpiler: &mut Transpiler<'a>, bump: &'a Arena, ) -> Result<(), crate::Error>; + /// Bundle thread, on dequeue: `false` if the owner released this build + /// while it was still queued ([`free_released_unstarted`] then frees it). + fn try_start(&mut self) -> bool; + fn free_released_unstarted(this: *mut Self); fn complete_on_bundle_thread(&mut self); fn set_result(&mut self, result: BundleV2Result); fn set_log(&mut self, log: bun_ast::Log); @@ -74,8 +78,8 @@ pub trait CompletionStruct: Node + Send + 'static { /// `FileMap` layout stays in T6. fn file_map(&mut self) -> Option>; /// Returns a §Dispatch handle (erased owner + `&'static` vtable) the impl - /// provides, so the bundler can read `result == .err` / - /// `jsc_event_loop.enqueueTaskConcurrent` without naming the concrete + /// provides, so the bundler can read `result == .err` / `is_cancelled`, + /// and post plugin hops to the owning VM, without naming the concrete /// struct. fn as_js_bundle_completion_task(&mut self) -> dispatch::CompletionHandle; @@ -221,7 +225,13 @@ impl BundleThread { break; } // SAFETY: queue stores non-null *mut C pushed via enqueue(); owner keeps it alive - // until complete_on_bundle_thread() signals completion. + // until complete_on_bundle_thread() signals completion — unless it + // released the build while it sat here (its VM went away). + if !unsafe { (*completion).try_start() } { + C::free_released_unstarted(completion); + continue; + } + // SAFETY: as above; started ⇒ the owner waits for us. let completion = unsafe { &mut *completion }; // SAFETY: `generation` is only read/written on this (bundle) thread. let generation = unsafe { (*instance).generation }; diff --git a/src/bundler/DeferredBatchTask.rs b/src/bundler/DeferredBatchTask.rs index 15c406271a27..e2920d2d0550 100644 --- a/src/bundler/DeferredBatchTask.rs +++ b/src/bundler/DeferredBatchTask.rs @@ -17,6 +17,13 @@ pub struct DeferredBatchTask { running: bool, } +impl bun_event_loop::Taskable for DeferredBatchTask { + const TAG: bun_event_loop::TaskTag = task_tag::BundleV2DeferredBatchTask; + /// Embedded in its `BundleV2`, which outlives the queue entry and owns + /// everything the drain would have touched; nothing to free. + unsafe fn release_unrun(_: *mut Self) {} +} + impl DeferredBatchTask { pub(crate) fn init(&mut self) { // Kept as `&mut self` (not `-> Self`) — this struct is embedded @@ -47,12 +54,7 @@ impl DeferredBatchTask { debug_assert!(!self.running); self.running = false; } - // PORTING.md §Dispatch: tag+ptr, not TaggedPointer. Tag constant lives in - // `bun_event_loop::task_tag::BundleV2DeferredBatchTask`. - let task = ConcurrentTask::create(Task::new( - task_tag::BundleV2DeferredBatchTask, - std::ptr::from_mut::(self).cast::<()>(), - )); + let task = ConcurrentTask::create(Task::init(std::ptr::from_mut::(self))); self.get_bundle_v2().enqueue_on_js_loop_for_plugins(task); } diff --git a/src/bundler/Graph.rs b/src/bundler/Graph.rs index 62eab2e197d0..aa13706b0826 100644 --- a/src/bundler/Graph.rs +++ b/src/bundler/Graph.rs @@ -56,6 +56,15 @@ pub struct Graph<'a> { /// tasks will be run, and the count is "moved" back to `pending_items` pub(crate) deferred_pending: u32, + /// onResolve / onLoad requests a plugin currently holds (dispatched to its + /// VM, not yet answered). Bundle thread only. Failed wholesale when that + /// VM shuts down mid-build (`BundleV2::is_done`). + pub(crate) outstanding_resolves: OutstandingList, + pub(crate) outstanding_loads: OutstandingList, + /// The owning VM cancelled this pass; plugin requests were failed and no + /// deferred batch will run. + pub(crate) cancelled: bool, + /// A map of build targets to their corresponding module graphs. pub build_graphs: EnumMap, @@ -160,6 +169,9 @@ impl<'a> Graph<'a> { ast: MultiArrayList::default(), pending_items: 0, deferred_pending: 0, + outstanding_resolves: OutstandingList::default(), + outstanding_loads: OutstandingList::default(), + cancelled: false, build_graphs: EnumMap::default(), server_component_boundaries: server_component_boundary::List::default(), html_imports: HtmlImports::default(), @@ -221,6 +233,15 @@ impl<'a> Graph<'a> { if self.deferred_pending > 0 { self.pending_items += self.deferred_pending; self.deferred_pending = 0; + // Their units are back in `pending_items`. + let mut load = self.outstanding_loads.head; + while !load.is_null() { + // SAFETY: linked ⇒ arena-live; bundle thread. + unsafe { + (*load).deferred = false; + load = (*load).outstanding.next; + } + } transpiler.drain_defer_task.init(); transpiler.drain_defer_task.schedule(); @@ -237,3 +258,82 @@ impl<'a> Graph<'a> { // here so `InputFile` and the derived `items_side_effects()` SoA accessor share // the same type that `LinkerContext::mark_file_live_for_tree_shaking` expects. use bun_ast::SideEffects; + +/// Intrusive doubly-linked membership in an [`OutstandingList`]. +pub struct OutstandingLink { + prev: *mut T, + pub(crate) next: *mut T, + linked: bool, +} +impl Default for OutstandingLink { + fn default() -> Self { + Self { + prev: core::ptr::null_mut(), + next: core::ptr::null_mut(), + linked: false, + } + } +} +pub trait OutstandingNode: Sized { + fn link(&mut self) -> &mut OutstandingLink; +} +/// A bundle pass's outstanding plugin requests; single-threaded (bundle thread). +pub struct OutstandingList { + head: *mut T, +} +impl Default for OutstandingList { + fn default() -> Self { + Self { + head: core::ptr::null_mut(), + } + } +} +impl OutstandingList { + pub(crate) fn push(&mut self, node: *mut T) { + // SAFETY: `node` is arena-live and unlinked; bundle thread. + unsafe { + let l = (*node).link(); + debug_assert!(!l.linked); + l.linked = true; + l.prev = core::ptr::null_mut(); + l.next = self.head; + if !self.head.is_null() { + (*self.head).link().prev = node; + } + } + self.head = node; + } + /// No-op if `node` is not linked (already answered / never dispatched). + pub(crate) fn unlink(&mut self, node: &mut T) { + let node_ptr: *mut T = node; + let l = node.link(); + if !l.linked { + return; + } + l.linked = false; + let (prev, next) = (l.prev, l.next); + l.prev = core::ptr::null_mut(); + l.next = core::ptr::null_mut(); + // SAFETY: neighbours are linked ⇒ arena-live; bundle thread. + unsafe { + if prev.is_null() { + debug_assert!(core::ptr::eq(self.head, node_ptr)); + self.head = next; + } else { + (*prev).link().next = next; + } + if !next.is_null() { + (*next).link().prev = prev; + } + } + } + pub(crate) fn pop(&mut self) -> Option<*mut T> { + let head = self.head; + if head.is_null() { + return None; + } + // SAFETY: linked ⇒ arena-live. + self.unlink(unsafe { &mut *head }); + Some(head) + } +} diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index cea5989fb7fb..841a828cce53 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2841,15 +2841,27 @@ pub mod parse_worker { .any_loop_mut() .expect("BundleV2.linker.loop must be set before scheduling ParseTask") { - bun_event_loop::AnyEventLoop::Js { owner } => { - owner.enqueue_task_concurrent( + bun_event_loop::AnyEventLoop::Js { .. } => { + let ct = bun_event_loop::ConcurrentTask::ConcurrentTask::from_callback(result, |p| { // SAFETY: `p` is the `result` Box leaked above; ownership // transfers to `on_complete`, which deallocates it. unsafe { on_complete(p) }; Ok(()) - }), - ); + }); + let poster = worker + .ctx + .js_poster + .as_ref() + .expect("JS-owned bundle has a poster"); + if let bun_event_loop::Posted::Refused(ct) = poster.post(ct) { + // Owning JS VM torn down mid-bundle: free the hop and the result. + // SAFETY: refused ⇒ we own the task box and the leaked result. + unsafe { + bun_event_loop::ConcurrentTask::ConcurrentTask::release_refused(ct); + drop(bun_core::heap::take(result)); + } + } } bun_event_loop::AnyEventLoop::Mini(mini) => { // SAFETY: `result` is a valid heap pointer with `task` at the given offset; diff --git a/src/bundler/ServerComponentParseTask.rs b/src/bundler/ServerComponentParseTask.rs index d5b47a833235..b2f10b316297 100644 --- a/src/bundler/ServerComponentParseTask.rs +++ b/src/bundler/ServerComponentParseTask.rs @@ -116,15 +116,26 @@ fn task_callback_wrap(thread_pool_task: *mut ThreadPoolTask) { .any_loop_mut() .expect("BundleV2.linker.loop must be set before scheduling ServerComponentParseTask") { - bun_event_loop::AnyEventLoop::Js { owner } => { - owner.enqueue_task_concurrent( - bun_event_loop::ConcurrentTask::ConcurrentTask::from_callback(result, |p| { - // SAFETY: `p` is the `result` Box leaked above; ownership - // transfers to `on_complete`, which deallocates it. - unsafe { on_complete(p) }; - Ok(()) - }), - ); + bun_event_loop::AnyEventLoop::Js { .. } => { + let ct = bun_event_loop::ConcurrentTask::ConcurrentTask::from_callback(result, |p| { + // SAFETY: `p` is the `result` Box leaked above; ownership + // transfers to `on_complete`, which deallocates it. + unsafe { on_complete(p) }; + Ok(()) + }); + let poster = worker + .ctx + .js_poster + .as_ref() + .expect("JS-owned bundle has a poster"); + if let bun_event_loop::Posted::Refused(ct) = poster.post(ct) { + // Owning JS VM torn down mid-bundle: free the hop and the result. + // SAFETY: refused ⇒ we own the task box and the leaked result. + unsafe { + bun_event_loop::ConcurrentTask::ConcurrentTask::release_refused(ct); + drop(bun_core::heap::take(result)); + } + } } bun_event_loop::AnyEventLoop::Mini(mini) => { // SAFETY: `result` is a freshly Box-leaked `parse_task::Result` (above) and diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 9266480575ab..17ac4efe2b1d 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -92,6 +92,9 @@ pub struct BundleV2<'a> { pub bun_watcher: Option>, pub plugins: Option>, pub completion: Option, + /// When this bundle's owning loop is a JS event loop (bake / dev server): + /// how parse worker threads deliver work back to it. + pub js_poster: Option, /// CYCLEBREAK GENUINE: erased `bake::DevServer` (see `dispatch::DevServerHandle`). /// Populated from `transpiler.options.dev_server` + the runtime-registered vtable at /// construction. All ~15 DevServer call sites go through this. @@ -1085,6 +1088,9 @@ pub mod bv2_impl { pub value: ResolveValue, /// `jsc.AnyEventLoop.Task` — intrusive node for the Mini-loop queue. pub(crate) task: bun_event_loop::AnyTaskWithExtraContext::AnyTaskWithExtraContext, + /// Links in the bundle's list of requests a plugin currently + /// holds (`Graph::outstanding_resolves`); bundle thread only. + pub(crate) outstanding: crate::Graph::OutstandingLink, } impl Default for Resolve { fn default() -> Self { @@ -1093,12 +1099,16 @@ pub mod bv2_impl { import_record: MiniImportRecord::default(), value: ResolveValue::Pending, task: bun_event_loop::AnyTaskWithExtraContext::AnyTaskWithExtraContext::default(), + outstanding: Default::default(), } } } impl bun_event_loop::Taskable for Resolve { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::BundleV2PluginResolve; + /// Arena-owned by a bundle pass that already failed its + /// outstanding requests when it was cancelled; nothing to free. + unsafe fn release_unrun(_: *mut Self) {} } impl Resolve { pub(crate) fn init(bv2: &mut BundleV2<'_>, record: MiniImportRecord) -> Self { @@ -1109,16 +1119,29 @@ pub mod bv2_impl { import_record: record, value: ResolveValue::Pending, task: bun_event_loop::AnyTaskWithExtraContext::AnyTaskWithExtraContext::default(), + outstanding: Default::default(), } } - /// Hops to the JS thread to call the `onResolve` plugin chain. + /// Hops to the JS thread to call the `onResolve` plugin chain — + /// unless the pass is already cancelled (that VM is stopping and + /// will never answer): then the request fails here and now. pub(crate) fn dispatch(&mut self) { - let task = bun_event_loop::ConcurrentTask::ConcurrentTask::create( - bun_event_loop::Task::init(std::ptr::from_mut::(self)), - ); // SAFETY: `bv2` is a valid backref set by `init`; plugins is // Some (asserted by `enqueue_on_js_loop_for_plugins`). - unsafe { (*self.bv2).enqueue_on_js_loop_for_plugins(task) }; + unsafe { + let bv2 = &mut *self.bv2; + bv2.graph.outstanding_resolves.push(self); + if bv2.graph.cancelled { + // Failed by `is_done` at the loop's top level (not + // here, mid-caller); make sure it runs again. + bv2.wake_own_loop(); + return; + } + let task = bun_event_loop::ConcurrentTask::ConcurrentTask::create( + bun_event_loop::Task::init(std::ptr::from_mut::(self)), + ); + bv2.enqueue_on_js_loop_for_plugins(task); + } } pub fn run_on_js_thread(&mut self) { let kind = self.import_record.kind; @@ -1178,10 +1201,15 @@ pub mod bv2_impl { pub was_file: bool, /// Defer may only be called once. pub called_defer: bool, + /// `.defer()`ed and not yet drained: its scan-counter unit sits in + /// `Graph::deferred_pending` (bundle thread only). + pub(crate) deferred: bool, /// `jsc.AnyEventLoop.Task` — intrusive node for the Mini-loop queue /// (used by `onDefer` to notify the bundler thread when it runs /// under a `MiniEventLoop`). pub task: bun_event_loop::AnyTaskWithExtraContext::AnyTaskWithExtraContext, + /// Links in `Graph::outstanding_loads`; bundle thread only. + pub(crate) outstanding: crate::Graph::OutstandingLink, } impl Load { pub(crate) fn init(bv2: &mut BundleV2<'_>, parse: &mut ParseTask) -> Self { @@ -1199,7 +1227,9 @@ pub mod bv2_impl { namespace: parse.path.namespace.to_vec().into_boxed_slice(), was_file: false, called_defer: false, + deferred: false, task: bun_event_loop::AnyTaskWithExtraContext::AnyTaskWithExtraContext::default(), + outstanding: Default::default(), } } /// Shared access to the heap-allocated `ParseTask` this load wraps. @@ -1227,15 +1257,25 @@ pub mod bv2_impl { pub(crate) fn bake_graph(&self) -> crate::bake_types::Graph { self.parse_task().known_target.bake_graph() } - /// Hops to the JS thread to call the `onLoad` plugin chain. + /// Hops to the JS thread to call the `onLoad` plugin chain — + /// unless the pass is already cancelled: see `Resolve::dispatch`. pub(crate) fn dispatch(&mut self) { - let concurrent_task = bun_event_loop::ConcurrentTask::ConcurrentTask::create( - bun_event_loop::Task::init(std::ptr::from_mut::(self)), - ); // SAFETY: `bv2` is a valid backref; plugins is Some (asserted // by `enqueue_on_js_loop_for_plugins`). unsafe { - (*self.bv2).enqueue_on_js_loop_for_plugins(concurrent_task); + let bv2 = &mut *self.bv2; + bv2.graph.outstanding_loads.push(self); + if bv2.graph.cancelled { + // Failed by `is_done` at the loop's top level (not + // here, mid-caller); make sure it runs again. + bv2.wake_own_loop(); + return; + } + let concurrent_task = + bun_event_loop::ConcurrentTask::ConcurrentTask::create( + bun_event_loop::Task::init(std::ptr::from_mut::(self)), + ); + bv2.enqueue_on_js_loop_for_plugins(concurrent_task); } } pub fn run_on_js_thread(&mut self) { @@ -1262,6 +1302,18 @@ pub mod bv2_impl { } impl bun_event_loop::Taskable for Load { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::BundleV2PluginLoad; + /// As `Resolve`: arena-owned by its (cancelled) bundle pass. + unsafe fn release_unrun(_: *mut Self) {} + } + impl crate::Graph::OutstandingNode for Load { + fn link(&mut self) -> &mut crate::Graph::OutstandingLink { + &mut self.outstanding + } + } + impl crate::Graph::OutstandingNode for Resolve { + fn link(&mut self) -> &mut crate::Graph::OutstandingLink { + &mut self.outstanding + } } } } @@ -1375,6 +1427,9 @@ pub mod bv2_impl { pub struct CompletionDispatch { /// Whether the completion result is an error. pub result_is_err: unsafe fn(core::ptr::NonNull) -> bool, + /// Whether the VM that owns the plugins is shutting down: stop + /// waiting for their answers and fail the build (any thread). + pub is_cancelled: unsafe fn(core::ptr::NonNull) -> bool, /// Folds the event-loop field access + enqueue so the bundler /// needn't name the JSC event-loop type. pub enqueue_task_concurrent: unsafe fn( @@ -1405,6 +1460,11 @@ pub mod bv2_impl { unsafe { (self.vtable.result_is_err)(self.owner) } } #[inline] + pub(crate) fn is_cancelled(&self) -> bool { + // SAFETY: vtable contract. + unsafe { (self.vtable.is_cancelled)(self.owner) } + } + #[inline] pub(crate) fn enqueue_task_concurrent( &self, task: core::ptr::NonNull, @@ -1471,20 +1531,22 @@ pub mod bv2_impl { ) { debug_assert!(self.plugins.is_some()); if let Some(completion) = self.completion { - // From Bun.build — `completion.jsc_event_loop.enqueueTaskConcurrent(task)`. + // From Bun.build — the completion posts it to its VM (`loop_handle.post_task` via the vtable). completion.enqueue_task_concurrent(task); return; } // From bake where the loop running the bundle is also the loop running // the plugins. // `any_loop_mut` centralises the BACKREF deref of `linker.r#loop`. - match &*self.any_loop_mut() { - bun_event_loop::AnyEventLoop::Js { owner } => { - owner.enqueue_task_concurrent(task); - } - bun_event_loop::AnyEventLoop::Mini(_) => { - panic!("No JavaScript event loop for transpiler plugins to run on"); - } + let poster = self + .js_poster + .as_ref() + .expect("No JavaScript event loop for transpiler plugins to run on"); + if let bun_event_loop::Posted::Refused(task) = poster.post(task) { + // The JS VM running the plugins was torn down mid-bundle; the + // plugin hop will never run. Free the task if it is heap-owned. + // SAFETY: refused ⇒ still ours. + unsafe { bun_event_loop::ConcurrentTask::ConcurrentTask::release_refused(task) }; } } @@ -1972,6 +2034,38 @@ pub mod bv2_impl { fn is_done(&mut self) -> bool { self.thread_lock.assert_locked(); + if self.completion.as_ref().is_some_and(|c| c.is_cancelled()) { + // The VM that owns the plugins is shutting down: no answer will + // come for what they hold. Take the answers already delivered, + // fail the rest here, and wait only for our own parse tasks. + if !self.graph.cancelled { + self.graph.cancelled = true; + // The pass as a whole fails at its next checkpoint. + self.transpiler.log_mut().add_error( + None, + bun_ast::Loc::EMPTY, + &b"Bun.build was cancelled: the VM that started it shut down"[..], + ); + } + // Every check, not just the first: `dispatch()` refuses new + // requests once cancelled, but one may have been linked between + // the completion's flag flipping and this thread observing it. + // Answers already sitting in our queue are consumed first (each + // unlinks its request), so nothing is failed here and then + // answered again. + let this: *mut Self = self; + // SAFETY: `linker.r#loop` is the Mini loop owned by this + // bundle pass's stack frame; the tasks it runs re-enter + // `*this` exactly as `tick_once` would between `is_done` calls. + unsafe { + if let bun_event_loop::AnyEventLoop::Mini(mini) = &mut *(*this).any_loop_mut() { + mini.run_ready(this.cast()); + } + } + self.fail_outstanding_plugin_requests(); + return self.graph.pending_items == 0; + } + if self.graph.pending_items == 0 { let this: *mut Self = self; // reshaped for borrowck — `&self.graph` and @@ -1988,6 +2082,57 @@ pub mod bv2_impl { false } + /// Bundle thread: make the pass's own Mini loop return from its poll so + /// `is_done` is evaluated again. + pub(crate) fn wake_own_loop(&mut self) { + if let bun_event_loop::AnyEventLoop::Mini(mini) = self.any_loop_mut() { + mini.wakeup(); + } + } + + /// Every onResolve/onLoad a plugin still holds is answered with an + /// error (bundle thread; the plugins' VM runs no more script). + fn fail_outstanding_plugin_requests(&mut self) { + fn cancelled_msg(file: &[u8]) -> bun_ast::Msg { + bun_ast::Msg { + data: bun_ast::Data { + text: std::borrow::Cow::Borrowed( + b"Bun.build was cancelled: the VM that owns its plugins shut down", + ), + location: Some(bun_ast::Location { + file: std::borrow::Cow::Owned(file.to_vec()), + line: -1, + column: -1, + ..Default::default() + }), + }, + ..Default::default() + } + } + while let Some(resolve) = self.graph.outstanding_resolves.pop() { + // SAFETY: linked ⇒ arena-live for this pass and held by no one else now. + let resolve = unsafe { &mut *resolve }; + resolve.value = jsc_api::JSBundler::ResolveValue::Err(cancelled_msg( + &resolve.import_record.source_file, + )); + Self::on_resolve(resolve, self); + } + while let Some(load) = self.graph.outstanding_loads.pop() { + // SAFETY: as above. + let load = unsafe { &mut *load }; + if load.deferred { + // Its unit is parked in `deferred_pending`, not `pending_items`. + load.deferred = false; + self.graph.deferred_pending -= 1; + drop(core::mem::take(&mut load.path)); + drop(core::mem::take(&mut load.namespace)); + continue; + } + load.value = jsc_api::JSBundler::LoadValue::Err(cancelled_msg(&load.path)); + Self::on_load(load, self); + } + } + pub(crate) fn wait_for_parse(&mut self) { // `tick_raw` (not `tick`) — `is_done` reborrows `*ctx` as // `&mut BundleV2`, and `BundleV2` (via `linker.r#loop`) owns the @@ -2700,6 +2845,9 @@ pub mod bv2_impl { bun_watcher: None, plugins: None, completion: None, + // SAFETY: `event_loop`, when set, points at the caller's live loop + // (owning thread == this thread). + js_poster: event_loop.and_then(|l| unsafe { l.as_ref() }.js_poster()), dev_server: None, file_map: None, source_code_length: 0, @@ -4176,13 +4324,22 @@ pub mod bv2_impl { // `on_load` must land there — not on the JS plugin loop — or it will // mutate `graph` / allocate from `graph.heap` off-thread. match self.any_loop_mut() { - bun_event_loop::AnyEventLoop::Js { owner } => { - owner.enqueue_task_concurrent( - bun_event_loop::ConcurrentTask::ConcurrentTask::from_callback( - std::ptr::from_mut(load), - on_load_from_js_loop_raw, - ), + bun_event_loop::AnyEventLoop::Js { .. } => { + let ct = bun_event_loop::ConcurrentTask::ConcurrentTask::from_callback( + std::ptr::from_mut(load), + on_load_from_js_loop_raw, ); + let poster = self + .js_poster + .as_ref() + .expect("JS-owned bundle has a poster"); + if let bun_event_loop::Posted::Refused(ct) = poster.post(ct) { + // Owning JS VM torn down mid-bundle: the hop never runs. + // SAFETY: refused ⇒ we own the task. + unsafe { + bun_event_loop::ConcurrentTask::ConcurrentTask::release_refused(ct) + }; + } } bun_event_loop::AnyEventLoop::Mini(mini) => { // SAFETY: `load` is a valid &mut for the duration of the enqueue; @@ -4201,13 +4358,22 @@ pub mod bv2_impl { pub fn on_resolve_async(&mut self, resolve: &mut jsc_api::JSBundler::Resolve) { // See `on_load_async` — must dispatch on the bundler's own loop. match self.any_loop_mut() { - bun_event_loop::AnyEventLoop::Js { owner } => { - owner.enqueue_task_concurrent( - bun_event_loop::ConcurrentTask::ConcurrentTask::from_callback( - std::ptr::from_mut(resolve), - on_resolve_from_js_loop_raw, - ), + bun_event_loop::AnyEventLoop::Js { .. } => { + let ct = bun_event_loop::ConcurrentTask::ConcurrentTask::from_callback( + std::ptr::from_mut(resolve), + on_resolve_from_js_loop_raw, ); + let poster = self + .js_poster + .as_ref() + .expect("JS-owned bundle has a poster"); + if let bun_event_loop::Posted::Refused(ct) = poster.post(ct) { + // Owning JS VM torn down mid-bundle: the hop never runs. + // SAFETY: refused ⇒ we own the task. + unsafe { + bun_event_loop::ConcurrentTask::ConcurrentTask::release_refused(ct) + }; + } } bun_event_loop::AnyEventLoop::Mini(mini) => { // SAFETY: `resolve` is a valid &mut for the duration of the enqueue; @@ -4252,6 +4418,8 @@ pub mod bv2_impl { impl<'a> BundleV2<'a> { pub(crate) fn on_load(load: &mut jsc_api::JSBundler::Load, this: &mut BundleV2) { + this.graph.outstanding_loads.unlink(load); + load.deferred = false; // `Load` is arena-allocated (no Drop); free its owned heap fields on every exit path. struct LoadDeinitGuard(*mut jsc_api::JSBundler::Load); impl Drop for LoadDeinitGuard { @@ -4449,6 +4617,7 @@ pub mod bv2_impl { impl<'a> BundleV2<'a> { pub(crate) fn on_resolve(resolve: &mut jsc_api::JSBundler::Resolve, this: &mut BundleV2) { + this.graph.outstanding_resolves.unlink(resolve); // RAII guard captures `this` // as a raw pointer so it does not hold a unique borrow across the body. let _dec_guard = this.decrement_scan_counter_on_drop(); @@ -6788,7 +6957,8 @@ pub mod bv2_impl { self.decrement_scan_counter(); } - pub fn on_notify_defer_mini(_: &mut jsc_api::JSBundler::Load, this: &mut BundleV2) { + pub fn on_notify_defer_mini(load: &mut jsc_api::JSBundler::Load, this: &mut BundleV2) { + load.deferred = true; this.on_notify_defer(); } diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 52ecde0c646c..ffba2316eae1 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -459,6 +459,14 @@ impl<'a> Transpiler<'a> { pub fn resolve_entry_point(&mut self, entry_point: &[u8]) -> crate::Result { match self._resolve_entry_point(entry_point) { Ok(r) => Ok(r), + // Nothing that long names a directory whose cache could be stale + // (and the join below has a PathBuffer to fit `top_level_dir/entry/..` in). + Err(err) + if self.fs().top_level_dir.len() + entry_point.len() + 4 + > bun_paths::MAX_PATH_BYTES => + { + Err(err) + } Err(err) => { let mut cache_bust_buf = bun_paths::PathBuffer::uninit(); diff --git a/src/css_jsc/color_js.rs b/src/css_jsc/color_js.rs index 70a53728a33c..25174bb47cf5 100644 --- a/src/css_jsc/color_js.rs +++ b/src/css_jsc/color_js.rs @@ -294,21 +294,9 @@ pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult } } } else if args[0].is_object() { - let r = color_int_from_js( - global, - args[0].get(global, b"r")?.unwrap_or(JSValue::ZERO), - "r", - )?; - let g = color_int_from_js( - global, - args[0].get(global, b"g")?.unwrap_or(JSValue::ZERO), - "g", - )?; - let b = color_int_from_js( - global, - args[0].get(global, b"b")?.unwrap_or(JSValue::ZERO), - "b", - )?; + let r = color_int_from_js(global, args[0].get(global, b"r")?.unwrap_or_default(), "r")?; + let g = color_int_from_js(global, args[0].get(global, b"g")?.unwrap_or_default(), "g")?; + let b = color_int_from_js(global, args[0].get(global, b"b")?.unwrap_or_default(), "b")?; let a: Option = if let Some(a_value) = args[0].get_truthy(global, b"a")? { 'brk2: { diff --git a/src/event_loop/AnyEventLoop.rs b/src/event_loop/AnyEventLoop.rs index 026d61e84f40..d5978e8bb1a7 100644 --- a/src/event_loop/AnyEventLoop.rs +++ b/src/event_loop/AnyEventLoop.rs @@ -67,6 +67,15 @@ 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. + pub fn js_poster(&self) -> Option { + match self { + AnyEventLoop::Js { owner } => Some(owner.js_poster()), + AnyEventLoop::Mini(_) => None, + } + } + pub fn iteration_number(&self) -> u64 { match self { AnyEventLoop::Js { owner } => owner.iteration_number(), @@ -237,14 +246,6 @@ fn mini_mut<'a>(mini: &'a mut BackRef) -> &'a mut MiniEventL unsafe { mini.get_mut() } } -/// Untagged pointer to either kind of concurrent task. Tag is the surrounding -/// `EventLoopHandle` discriminant. -#[derive(Copy, Clone)] -pub union EventLoopTaskPtr { - pub js: *mut ConcurrentTask, - pub mini: *mut AnyTaskWithExtraContext, -} - /// Owned storage for either kind of concurrent task. pub enum EventLoopTask { Js(ConcurrentTask), @@ -432,21 +433,13 @@ impl EventLoopHandle { EnteredEventLoop(self) } - pub fn enqueue_task_concurrent(self, task: EventLoopTaskPtr) { + /// 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). + pub fn js_poster(&self) -> Option { match self { - EventLoopHandle::Js { owner } => { - // SAFETY: caller guarantees `task.js` is the active union member - // when `self` is `Js`, and points at a live `ConcurrentTask` - // (non-null). - owner.enqueue_task_concurrent(unsafe { NonNull::new_unchecked(task.js) }) - } - EventLoopHandle::Mini(mut mini) => { - // SAFETY: caller guarantees `task.mini` is the active union - // member when `self` is `Mini`, and that it points at a live - // `AnyTaskWithExtraContext` (always non-null). - let task = unsafe { NonNull::new_unchecked(task.mini) }; - mini_mut(&mut mini).enqueue_task_concurrent(task); - } + EventLoopHandle::Js { owner } => Some(owner.js_poster()), + EventLoopHandle::Mini(_) => None, } } @@ -529,3 +522,88 @@ 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 +/// thread. +#[must_use = "a refused task must be released by its producer"] +pub enum Posted { + Queued, + Refused(NonNull), +} + +pub struct JsPosterVTable { + pub post: unsafe fn(data: *const (), task: NonNull) -> 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 ()), +} + +pub struct JsPoster { + data: *const (), + vtable: &'static JsPosterVTable, +} + +// SAFETY: `data` is an erased `Arc`; the vtable fns are the +// thread-safe VmHandle operations. +unsafe impl Send for JsPoster {} +// SAFETY: as above. +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). + #[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. + #[inline] + pub fn post(&self, task: NonNull) -> 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 { + fn clone(&self) -> Self { + Self { + // SAFETY: vtable contract. + data: unsafe { (self.vtable.clone)(self.data) }, + vtable: self.vtable, + } + } +} + +impl Drop for JsPoster { + fn drop(&mut self) { + // SAFETY: vtable contract. + unsafe { (self.vtable.drop)(self.data) } + } +} diff --git a/src/event_loop/ConcurrentTask.rs b/src/event_loop/ConcurrentTask.rs index d566c0245c3c..80dcef9d439f 100644 --- a/src/event_loop/ConcurrentTask.rs +++ b/src/event_loop/ConcurrentTask.rs @@ -50,6 +50,8 @@ pub mod task_tag { /// Number of task tags. `bun_runtime::dispatch::run_task` asserts /// exhaustiveness against this. pub const COUNT: u8 = tags!(@count 0u8, $($name,)*); + /// For diagnostics. + pub const NAMES: [&str; COUNT as usize] = [$(stringify!($name)),*]; }; (@ $n:expr, $head:ident, $($rest:ident,)*) => { pub const $head: TaskTag = TaskTag($n); @@ -60,95 +62,50 @@ pub mod task_tag { (@count $n:expr,) => { $n }; } tags! { - Access, - AnyTaskJob, // bun_jsc::AnyTaskJob (typed job, one erased slot inside) + AnyTaskJob, // bun_jsc::Job (typed pool job, one erased tag) AsyncModule, - AppendFile, - ArchiveExtractTask, - ArchiveBlobTask, - ArchiveWriteTask, - ArchiveFilesTask, - AsyncGlobWalkTask, - AsyncImageTask, - AsyncTransformTask, BakeHotReloadEvent, // bun.bake.DevServer.HotReloadEvent BundleV2DeferredBatchTask, // bun.bundle_v2.DeferredBatchTask BundleV2PluginResolve, // bun.bundle_v2.Resolve (JS-thread hop) BundleV2PluginLoad, // bun.bundle_v2.Load (JS-thread hop) ShellYesTask, // shell.Interpreter.Builtin.Yes.YesTask - Chmod, - Chown, Close, - CopyFile, - CopyFilePromiseTask, CppTask, DuplexUpgradeContext, - Exists, - Fchmod, - FChown, - Fdatasync, FetchTasklet, + FetchTaskletDeinit, FetchTaskletPromiseSettle, FileResponseStreamEof, - Fstat, FSWatchTask, - Fsync, - FTruncate, - Futimes, - GetAddrInfoRequestTask, GetAddrInfoLibuvComplete, HotReloadTask, WatchReloadTask, - ImmediateObject, JSBundleCompletionTask, JSCDeferredWorkTask, - Lchmod, - Lchown, - Link, - Lstat, - Lutimes, ManagedTask, - Mkdir, - Mkdtemp, NapiAsyncWork, // napi_async_work NapiFinalizerTask, NativePromiseContextDeferredDerefTask, NativeBrotli, NativeZlib, NativeZstd, - CompressionStreamCoderTask, Open, - PasswordHashResult, - PasswordVerifyResult, PollPendingModulesTask, PosixSignalTask, MemoryPressureTask, ProcessWaiterThreadTask, Read, - Readdir, - ReaddirRecursive, - ReadFile, - ReadFileTask, - Readlink, Readv, FlushPendingFileSinkTask, - Realpath, - RealpathNonNative, - Rename, - Rm, - Rmdir, RuntimeTranspilerStore, S3HttpDownloadStreamingTask, S3HttpSimpleTask, SendQueueDeferred, // bun_runtime::ipc::SendQueue (close / after-close hop) ServerAllConnectionsClosedTask, ShellAsync, - ShellAsyncSubprocessDone, ShellCondExprStatTask, ShellCpTask, ShellGlobTask, - ShellIOReaderAsyncDeinit, - ShellIOWriterAsyncDeinit, ShellLsTask, ShellMkdirTask, ShellMvBatchedTask, @@ -156,21 +113,16 @@ pub mod task_tag { ShellRmDirTask, ShellRmTask, ShellTouchTask, - Stat, StatFS, StatWatcherTimerUpdate, + StatWatcherHop, + AsyncCpTask, + ShellAsyncCpTask, StreamPending, - Symlink, ThreadSafeFunction, - TimeoutObject, - Truncate, - Unlink, - Utimes, ValkeyDeferredClose, WindowsNamedPipeContext, Write, - WriteFile, - WriteFileTask, Writev, } } @@ -181,22 +133,43 @@ pub struct Task { pub ptr: *mut (), } -/// Type → tag binding for [`Task`]. Implement on every type that can be +/// What it takes to be queued as a [`Task`]: a tag, and how the task is +/// freed when it will never run. Implement on every type that can be /// enqueued; the impl lives in whatever crate owns the type. /// -/// ```ignore -/// impl bun_event_loop::Taskable for FetchTasklet { -/// const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::FetchTasklet; -/// } -/// ``` +/// 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 — +/// [`release_unrun`](Self::release_unrun), required here so no type can be +/// queued without having decided it. /// /// Re-exported from `bun_jsc` for ergonomics, but defined here (lowest tier on /// the hot-dispatch list, see PORTING.md §Dispatch) so that /// [`Task::init`] can use it without a dep cycle. pub trait Taskable { /// The tag constant from [`task_tag`] for this type. Both this and the - /// `bun_runtime::dispatch::run_task` match arm MUST agree. + /// `bun_runtime::dispatch` match arms MUST agree. const TAG: TaskTag; + + /// The task is in its VM's queue and will never be dispatched (the VM is + /// tearing down: script is forbidden and the loop no longer ticks). Free + /// it and whatever it holds — keep-alives, JS handles, refs, buffers — + /// without running it. JS thread, JSC heap still alive. `this` is the + /// queued [`Task::ptr`] (for the tags whose `ptr` packs an integer, that + /// value). A type that can never be in a queue at that point says so here + /// with `unreachable!` and the reason. + /// + /// # Safety + /// `this` came off the queue under `Self::TAG` and is not used afterwards. + unsafe fn release_unrun(this: *mut Self); +} + +impl TaskTag { + /// The tag's identifier, for diagnostics. + pub fn name(self) -> &'static str { + task_tag::NAMES.get(self.0 as usize).copied().unwrap_or("?") + } } impl Task { @@ -226,6 +199,10 @@ impl Task { // Taskable impls for the low-tier task wrappers defined in this crate. impl Taskable for crate::ManagedTask::ManagedTask { const TAG: TaskTag = task_tag::ManagedTask; + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — a queued ManagedTask is the heap box `new*` made. + unsafe { crate::ManagedTask::ManagedTask::release(this) } + } } // ──────────────────────────────────────────────────────────────────────────── @@ -335,6 +312,26 @@ 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. + /// + /// # Safety + /// `task` was just refused and is not queued anywhere. + pub unsafe fn release_refused(task: core::ptr::NonNull) { + // 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())); + } + } + } + /// Returns whether this task should be automatically deallocated after execution. #[inline] pub fn auto_delete(&self) -> bool { diff --git a/src/event_loop/ManagedTask.rs b/src/event_loop/ManagedTask.rs index fd7462f56164..fff72190d287 100644 --- a/src/event_loop/ManagedTask.rs +++ b/src/event_loop/ManagedTask.rs @@ -33,6 +33,18 @@ impl ManagedTask { callback(ctx.unwrap().as_ptr()) } + /// Free without running: the owned context (if `new_owned`) is dropped. + /// + /// # Safety + /// As [`run`](Self::run); the task is not queued anywhere. + pub unsafe fn release(this: *mut ManagedTask) { + // SAFETY: fn contract. + let this = unsafe { bun_core::heap::take(this) }; + if let (Some(cleanup), Some(ctx)) = (this.cleanup, this.ctx) { + cleanup(ctx.as_ptr()); + } + } + // A per-(Type, Callback) trampoline is folded away by storing // the type-erased fn pointer directly — `fn(*mut T)` and `fn(*mut c_void)` share ABI. pub fn new(ctx: *mut T, callback: fn(*mut T) -> JsResult<()>) -> Task { diff --git a/src/event_loop/MiniEventLoop.rs b/src/event_loop/MiniEventLoop.rs index 3cee4a328182..8c3bd7dd6a54 100644 --- a/src/event_loop/MiniEventLoop.rs +++ b/src/event_loop/MiniEventLoop.rs @@ -187,6 +187,14 @@ impl MiniEventLoop { self.loop_ } + /// Make a poll in progress (or the next one) return immediately, so the + /// `is_done` predicate a `tick` loop spins on is evaluated again. Any thread. + pub fn wakeup(&self) { + // SAFETY: `loop_` is the live uws loop; `us_wakeup_loop` is thread-safe + // and takes the raw pointer (no `&mut Loop` formed). + unsafe { bun_uws::us_wakeup_loop(self.loop_) }; + } + /// Raw pointer to the `DotEnv::Loader` backref. /// /// Returns `None` until [`init_global`] populates it. Neither a `&`- nor @@ -331,6 +339,21 @@ impl MiniEventLoop { } } + /// Run everything already delivered (concurrent + local queues) without + /// blocking for more. + pub fn run_ready(&mut self, context: *mut c_void) { + loop { + let _ = self.tick_concurrent_with_count(); + if self.tasks.readable_length() == 0 { + break; + } + while let Some(task) = self.tasks.read_item() { + // SAFETY: see tick_once. + unsafe { (*task).run(context) }; + } + } + } + pub(crate) fn tick_without_idle(&mut self, context: *mut c_void) { loop { let _ = self.tick_concurrent_with_count(); @@ -410,11 +433,6 @@ bun_io::link_impl_EventLoopCtx! { file_polls_ptr() => MiniEventLoop::file_polls_raw(this), // Mini has no pending_unref_counter; the upstream deliberately panics. increment_pending_unref_counter() => panic!("FIXME TODO"), - // `KeepAlive::{,un}refConcurrently` is JS-VM-only (statically rejected - // on Mini upstream); preserve that invariant rather than racily - // mutating uws counters off-thread. - ref_concurrently() => unreachable!("KeepAlive::refConcurrently is JS-VM-only"), - unref_concurrently() => unreachable!("KeepAlive::unrefConcurrently is JS-VM-only"), after_event_loop_callback() => (*this).after_event_loop_callback, set_after_event_loop_callback(cb, ctx) => { (*this).after_event_loop_callback = cb; diff --git a/src/event_loop/lib.rs b/src/event_loop/lib.rs index 35194cd85e6a..08a31e32cfb4 100644 --- a/src/event_loop/lib.rs +++ b/src/event_loop/lib.rs @@ -36,7 +36,9 @@ pub use ConcurrentTask::{Task, TaskTag, Taskable, task_tag}; pub use DeferredTaskQueue as deferred_task_queue; pub use MiniEventLoop::PipeReadBuffer; -pub use any_event_loop::{AnyEventLoop, EventLoopHandle, EventLoopTask, EventLoopTaskPtr}; +pub use any_event_loop::{ + AnyEventLoop, EventLoopHandle, EventLoopTask, JsPoster, JsPosterVTable, Posted, +}; // JS-event-loop arm of `AnyEventLoop` / `EventLoopHandle`. `bun_event_loop` is // a lower tier than `bun_jsc`, so it cannot name `jsc::EventLoop` / @@ -59,8 +61,7 @@ bun_dispatch::link_interface! { fn enter(); fn exit(); fn enqueue_task(task: Task); - fn enqueue_task_concurrent(task: core::ptr::NonNull); - fn concurrent_poster_end(); + fn js_poster() -> any_event_loop::JsPoster; fn env() -> *mut bun_dotenv::Loader; fn top_level_dir() -> *const [u8]; fn create_null_delimited_env_map() -> Result; diff --git a/src/http/HTTPThread.rs b/src/http/HTTPThread.rs index c832568d9d69..eaec4535e8d5 100644 --- a/src/http/HTTPThread.rs +++ b/src/http/HTTPThread.rs @@ -1032,6 +1032,25 @@ impl HttpThread { self.in_flight.len(), self.deferred_tasks.len() ); + // Requests handed to us but never started (concurrency-deferred, or + // still on the incoming queue): the JS-side owner is waiting to get + // them back all the same. Nothing here was copied or connected, so + // `release_at_shutdown` is the whole story. + let release_unstarted = |http: NonNull>| { + // SAFETY: heap-owned by the caller, alive until its completion, + // and never touched by us again after this. + let release = unsafe { (*http.as_ptr()).result_callback }; + if let Some(f) = release.release_at_shutdown { + // SAFETY: paired ctx/fn from `HTTPClientResultCallback::new_with_release`. + unsafe { f(release.ctx) }; + } + }; + for http in core::mem::take(&mut self.deferred_tasks) { + release_unstarted(http); + } + while let Some(http) = NonNull::new(self.queued_tasks.pop()) { + release_unstarted(http); + } for nn in core::mem::take(&mut self.in_flight) { // SAFETY: every entry is the `heap::release` allocation pushed by // `start_queued_task`; HTTP-thread-only and removed at the @@ -1398,35 +1417,17 @@ static SHUTDOWN_DONE: (bun_threading::Guarded, bun_threading::Condvar) = ( bun_threading::Condvar::new(), ); -struct ShutdownReclaim { - ctx: *mut c_void, - drop_fn: unsafe fn(*mut c_void), -} -// SAFETY: pushed from the HTTP thread, drained from the JS thread once the -// HTTP thread is parked; `ctx` is an exclusive heap allocation handed off -// between the two. -unsafe impl Send for ShutdownReclaim {} - -static SHUTDOWN_RECLAIMS: bun_threading::Guarded> = - bun_threading::Guarded::new(Vec::new()); - -/// Park `(ctx, drop_fn)` until [`shutdown_for_exit`] has waited the HTTP -/// thread out of its loop. The drop is applied on the JS thread once the -/// daemon is parked, so callers can hand off allocations whose teardown is -/// not safe while a `tick()` is still on the HTTP-thread stack. -pub fn defer_shutdown_reclaim(ctx: *mut c_void, drop_fn: unsafe fn(*mut c_void)) { - SHUTDOWN_RECLAIMS - .lock() - .push(ShutdownReclaim { ctx, drop_fn }); -} - /// Called from `bun_jsc::VirtualMachine::global_exit()` on the JS thread, /// before `~VM`. Asks the HTTP daemon thread to reclaim every in-flight /// `ThreadlocalAsyncHTTP` box and waits (with a short timeout) for it to ack. /// No-op if the HTTP thread was never started. -pub fn shutdown_for_exit() { +/// Returns whether the HTTP thread is now parked (or was never running): +/// `false` means it did not acknowledge within the deadline and may still +/// touch requests, so the caller must not free anything it shares with it. +#[must_use] +pub fn shutdown_for_exit() -> bool { if !crate::HTTP_THREAD_INIT.load(Ordering::Acquire) { - return; + return true; } // SAFETY: `HTTP_THREAD_INIT == true` ⇒ `HTTP_THREAD` is fully written. // `get_unchecked` so the `ThreadCell` owner assert is skipped on this @@ -1441,7 +1442,7 @@ pub fn shutdown_for_exit() { if !thread.has_awoken.load(Ordering::Acquire) { // `on_start` hasn't published the loop yet — no `start_queued_task` // can have run, so no boxes exist. - return; + return true; } SHUTDOWN_REQUESTED.store(true, Ordering::Release); thread.wakeup(); @@ -1466,17 +1467,9 @@ pub fn shutdown_for_exit() { // Timed out without an ack: the HTTP thread may still be inside // `tick()` and could touch parked allocations. Leak them — the // process is exiting and a leak beats a use-after-free. - return; - } - - // The daemon is parked; no further callbacks will fire. Reclaim boxes - // that result-callback handlers parked here while the calling stack - // still aliased their contents. - for r in core::mem::take(&mut *SHUTDOWN_RECLAIMS.lock()) { - // SAFETY: `drop_fn` is paired with `ctx` by `defer_shutdown_reclaim`; - // each entry is pushed exactly once and drained exactly once here. - unsafe { (r.drop_fn)(r.ctx) }; + return false; } + true } // dispatch_deps bridge removed — real impls now live in diff --git a/src/http/lib.rs b/src/http/lib.rs index fc09c8b32956..5e27bd2f4e12 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -58,7 +58,7 @@ pub(crate) use http_cert_error::HTTPCertError; pub use http_context::{HTTPContext, HTTPSocket}; pub use http_request_body::HTTPRequestBody; pub use http_thread::HttpThread as HTTPThread; -pub use http_thread::{defer_shutdown_reclaim, shutdown_for_exit}; +pub use http_thread::shutdown_for_exit; pub use internal_state::InternalState; pub use proxy_tunnel::ProxyTunnel; pub use send_file::SendFile; diff --git a/src/http_jsc/websocket_client.rs b/src/http_jsc/websocket_client.rs index bfcbc0a09fed..2f6bad144c5d 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -1750,6 +1750,30 @@ impl WebSocket { } } + /// The owning C++ WebSocket's context is being torn down: forget it (nothing + /// here may call back into it or into script) and drop the connection now — + /// a raw close on TLS too, since no loop remains to finish a graceful one. + #[allow(clippy::not_unsafe_ptr_arg_deref)] + pub(crate) extern "C" fn drop_connection_without_callback(this_ptr: *mut Self) { + log!("dropConnectionWithoutCallback"); + // SAFETY: called from C++ with a valid `heap::alloc` pointer; the guard + // keeps the allocation alive across clear_data()/close re-entry. + let _guard = unsafe { bun_ptr::ScopedRef::new(this_ptr) }; + // SAFETY: as above. + let this = unsafe { &*this_ptr }; + + let had_cpp = this.outgoing_websocket.take().is_some(); + this.clear_data(); + if !this.tcp.get().is_closed() { + this.tcp.get().close(uws::CloseKind::Failure); + } + if had_cpp { + // The ref held on behalf of the C++ object. + // SAFETY: allocation kept live by the local guard above. + unsafe { Self::deref(this_ptr) }; + } + } + // `deinit` is the IntrusiveRc destructor callback; not `impl Drop` because // self is heap-allocated via heap::alloc and crosses FFI as *mut c_void. unsafe fn deinit(this: *mut Self) { @@ -1825,6 +1849,7 @@ macro_rules! export_websocket_client { cancel = $cancel:ident, close = $close:ident, finalize = $finalize:ident, + drop_connection_without_callback = $drop_connection_without_callback:ident, init = $init:ident, init_with_tunnel = $init_with_tunnel:ident, memory_cost = $memory_cost:ident, @@ -1845,6 +1870,10 @@ macro_rules! export_websocket_client { WebSocket::<$ssl>::finalize(this) } #[unsafe(no_mangle)] + pub extern "C" fn $drop_connection_without_callback(this: *mut WebSocket<$ssl>) { + WebSocket::<$ssl>::drop_connection_without_callback(this) + } + #[unsafe(no_mangle)] pub extern "C" fn $init( outgoing: *mut CppWebSocket, input_socket: *mut c_void, @@ -1915,6 +1944,7 @@ export_websocket_client!( cancel = Bun__WebSocketClient__cancel, close = Bun__WebSocketClient__close, finalize = Bun__WebSocketClient__finalize, + drop_connection_without_callback = Bun__WebSocketClient__dropConnectionWithoutCallback, init = Bun__WebSocketClient__init, init_with_tunnel = Bun__WebSocketClient__initWithTunnel, memory_cost = Bun__WebSocketClient__memoryCost, @@ -1927,6 +1957,7 @@ export_websocket_client!( cancel = Bun__WebSocketClientTLS__cancel, close = Bun__WebSocketClientTLS__close, finalize = Bun__WebSocketClientTLS__finalize, + drop_connection_without_callback = Bun__WebSocketClientTLS__dropConnectionWithoutCallback, init = Bun__WebSocketClientTLS__init, init_with_tunnel = Bun__WebSocketClientTLS__initWithTunnel, memory_cost = Bun__WebSocketClientTLS__memoryCost, diff --git a/src/http_jsc/websocket_client/CppWebSocket.rs b/src/http_jsc/websocket_client/CppWebSocket.rs index 0fe551c8646f..21bc471e115b 100644 --- a/src/http_jsc/websocket_client/CppWebSocket.rs +++ b/src/http_jsc/websocket_client/CppWebSocket.rs @@ -77,8 +77,8 @@ unsafe extern "C" { opcode: u8, ); safe fn WebSocket__rejectUnauthorized(websocket_context: &CppWebSocket) -> bool; - safe fn WebSocket__incrementPendingActivity(websocket_context: &CppWebSocket); - safe fn WebSocket__decrementPendingActivity(websocket_context: &CppWebSocket); + safe fn WebSocket__holdPendingActivityForClient(websocket_context: &CppWebSocket); + safe fn WebSocket__releasePendingActivityForClient(websocket_context: &CppWebSocket); fn WebSocket__setProtocol(websocket_context: &CppWebSocket, protocol: *mut BunString); } @@ -225,12 +225,12 @@ impl CppWebSocket { impl CppWebSocket { fn r#ref(&self) { bun_jsc::mark_binding!(); - WebSocket__incrementPendingActivity(self); + WebSocket__holdPendingActivityForClient(self); } fn unref(&self) { bun_jsc::mark_binding!(); - WebSocket__decrementPendingActivity(self); + WebSocket__releasePendingActivityForClient(self); } pub(crate) fn set_protocol(&self, protocol: &mut BunString) { diff --git a/src/install/lifecycle_script_runner.rs b/src/install/lifecycle_script_runner.rs index e94ae7a57ea4..5762cb3ffea6 100644 --- a/src/install/lifecycle_script_runner.rs +++ b/src/install/lifecycle_script_runner.rs @@ -758,13 +758,13 @@ impl<'a> LifecycleScriptSubprocess<'a> { // while libuv still has the handle queued (UAF) and the later // `close_impl`→`on_pipe_close`→`heap::take` double-frees. if let bun_spawn::SpawnedStdio::Buffer(pipe) = spawned.stdout.take() { - (*this).stdout.source = Some(bun_io::Source::Pipe(pipe)); + (*this).stdout.set_source(bun_io::Source::Pipe(pipe)); (*this).stdout.set_parent(this.cast::()); (*this).remaining_fds += 1; (*this).stdout.start_with_current_pipe()?; } if let bun_spawn::SpawnedStdio::Buffer(pipe) = spawned.stderr.take() { - (*this).stderr.source = Some(bun_io::Source::Pipe(pipe)); + (*this).stderr.set_source(bun_io::Source::Pipe(pipe)); (*this).stderr.set_parent(this.cast::()); (*this).remaining_fds += 1; (*this).stderr.start_with_current_pipe()?; diff --git a/src/io/PipeReader.rs b/src/io/PipeReader.rs index 4ac20d2947a1..149ed7c6db31 100644 --- a/src/io/PipeReader.rs +++ b/src/io/PipeReader.rs @@ -1444,6 +1444,9 @@ impl WindowsBufferedReader { self.flags = other.flags; self._buffer = mem::take(other.buffer()); self._offset = other._offset; + // Ownership of the handle (or listed file) moves with the source; + // `set_parent` below re-records this reader as the one a VM teardown + // stops it through. self.source = other.source.take(); other.flags.insert(WindowsFlags::IS_DONE); @@ -1476,7 +1479,7 @@ impl WindowsBufferedReader { // immutable-then-mutable-borrow conflict. let self_ptr = core::ptr::from_mut(self).cast::(); if let Some(source) = self.source.as_mut() { - source.set_data(self_ptr); + source.set_owner(self_ptr, Self::stop_for_vm_teardown); } } } @@ -1616,7 +1619,10 @@ impl WindowsBufferedReader { pub fn start_with_current_pipe(&mut self) -> sys::Result<()> { debug_assert!(!self.source.as_ref().unwrap().is_closed()); let self_ptr = core::ptr::from_mut(self).cast::(); - self.source.as_mut().unwrap().set_data(self_ptr); + self.source + .as_mut() + .unwrap() + .set_owner(self_ptr, Self::stop_for_vm_teardown); self.buffer().clear(); self.flags.remove(WindowsFlags::IS_DONE); // Debug-only fault injection for test/js/bun/spawn/spawn-pipe-start-error.test.ts: @@ -1635,21 +1641,47 @@ impl WindowsBufferedReader { #[cfg(windows)] pub unsafe fn start_with_pipe(&mut self, pipe: *mut uv::Pipe) -> sys::Result<()> { // SAFETY: caller contract — Box-allocated, ownership transfers. - self.source = Some(Source::Pipe(unsafe { bun_core::heap::take(pipe) })); + self.set_source(Source::Pipe(unsafe { bun_core::heap::take(pipe) })); self.start_with_current_pipe() } + /// Take ownership of `source` (reading starts later, or never). For a pipe + /// or tty this reader is from now on the one a VM teardown stops the handle + /// through — whether or not it ever starts reading — so nothing else may + /// close that handle while it sits here. + pub fn set_source(&mut self, source: Source) { + debug_assert!(self.source.is_none()); + self.source = Some(source); + let self_ptr = core::ptr::from_mut(self).cast::(); + if let Some(source) = self.source.as_mut() { + // A read over a file is a uv request with no handle to close: list + // the boxed File so a thread teardown closes this reader (`close()` + // below) before draining the loop. Unlisted where the box leaves + // this reader (`close_impl`, `Drop`). + if let Some(file) = source.file_key() { + uv::open_handles::add_file(file); + } + source.set_owner(self_ptr, Self::stop_for_vm_teardown); + } + } + + /// `uv::open_handles` closes this reader's stream through here at teardown. + unsafe fn stop_for_vm_teardown(this: *mut c_void) { + // SAFETY: recorded via `Source::set_owner` by this live reader; the slot + // is replaced/dropped before the reader goes away (close_impl / from / Drop). + unsafe { (*this.cast::()).close() }; + } + pub fn start(&mut self, fd: Fd, _: bool) -> sys::Result<()> { debug_assert!(self.source.is_none()); // Use the event loop from the parent, not the global one // This is critical for spawnSync to use its isolated loop let loop_ = self.vtable.loop_(); - let mut source = match Source::open(loop_.cast(), fd) { + let source = match Source::open(loop_.cast(), fd) { sys::Result::Err(err) => return sys::Result::Err(err), sys::Result::Ok(source) => source, }; - source.set_data(core::ptr::from_mut(self).cast::()); - self.source = Some(source); + self.set_source(source); self.start_with_current_pipe() } @@ -1785,8 +1817,9 @@ impl WindowsBufferedReader { // Mark no longer in flight this.flags.remove(WindowsFlags::HAS_INFLIGHT_READ); - // If canceled, check if we need to call deferred done - if was_canceled { + // Cancelled, or `close()` was asked for while this read was out (the + // cancel need not have won): finish the close, deliver nothing. + if was_canceled || this.flags.contains(WindowsFlags::DEFER_DONE_CALLBACK) { if this.flags.contains(WindowsFlags::DEFER_DONE_CALLBACK) { this.flags.remove(WindowsFlags::DEFER_DONE_CALLBACK); // Now safe to call done - buffer will be freed by deinit @@ -1926,7 +1959,7 @@ impl WindowsBufferedReader { debug_assert!(!source.is_closed()); match source { - Source::File(file) => { + Source::File(file) | Source::SyncFile(file) => { let file_raw: *mut crate::source::File = file.as_mut(); // SAFETY (each access below): `file_raw` points into the boxed // File owned by `self.source` — a heap allocation disjoint @@ -1977,7 +2010,7 @@ impl WindowsBufferedReader { return sys::Result::Err(err); } } - _ => { + Source::Pipe(_) | Source::Tty(_) => { // SAFETY: source is a live Pipe/Tty stream handle. if let Some(err) = unsafe { uv::uv_read_start( @@ -2013,11 +2046,11 @@ impl WindowsBufferedReader { return sys::Result::Ok(()); }; match source { - Source::File(file) => { + Source::File(file) | Source::SyncFile(file) => { file.stop(); } - _ => { - // SAFETY: stream handle is live (just matched non-File). + Source::Pipe(_) | Source::Tty(_) => { + // SAFETY: stream handle is live (just matched a stream source). unsafe { uv::uv_read_stop(source.to_stream()) }; } } @@ -2027,7 +2060,8 @@ impl WindowsBufferedReader { pub fn close_impl(&mut self) { if let Some(source) = self.source.take() { match source { - Source::SyncFile(file) | Source::File(file) => { + Source::SyncFile(mut file) | Source::File(mut file) => { + uv::open_handles::remove_file(core::ptr::from_mut(&mut *file).cast()); // Hand the Box off to libuv: detach() leaves either an // in-flight uv_fs_read (on_file_read) or a scheduled // uv_fs_close (on_close_complete) pending; the callback @@ -2038,6 +2072,12 @@ impl WindowsBufferedReader { // is the sole reclaimer (heap::take in on_close_complete / // on_file_read's detached path) when one is left pending. unsafe { + // A read in flight writes into `self._buffer` (via + // `iov`) whenever it completes; this reader may be + // dropped before then, so the buffer goes with the File. + if self.flags.contains(WindowsFlags::HAS_INFLIGHT_READ) { + (*raw).orphaned_read_buf = core::mem::take(&mut self._buffer); + } if self.flags.contains(WindowsFlags::CLOSE_HANDLE) { (*raw).detach(); } else if !(*raw).detach_borrowed_fd() { @@ -2247,6 +2287,10 @@ impl Drop for WindowsBufferedReader { self.source = Some(source); self.close_impl::(); } else { + let mut source = source; + if let Some(file) = source.file_key() { + uv::open_handles::remove_file(file); + } core::mem::forget(source); } } diff --git a/src/io/PipeWriter.rs b/src/io/PipeWriter.rs index d041e79edd57..69ec758b42d8 100644 --- a/src/io/PipeWriter.rs +++ b/src/io/PipeWriter.rs @@ -1094,7 +1094,7 @@ impl Drop for PosixStreamingWriter { /// fn start_with_current_pipe(&mut self) -> sys::Result<()>, /// fn on_close_source(&mut self), #[cfg(windows)] -pub trait BaseWindowsPipeWriter { +pub trait BaseWindowsPipeWriter: Sized { type Parent: WindowsWriterParent; fn source(&self) -> &Option; @@ -1106,6 +1106,26 @@ pub trait BaseWindowsPipeWriter { fn owns_fd(&self) -> bool; fn start_with_current_pipe(&mut self) -> sys::Result<()>; fn on_close_source(&mut self); + fn closed_without_reporting(&self) -> bool; + fn set_closed_without_reporting(&mut self, v: bool); + + /// `uv::open_handles` closes this writer's stream through here at teardown. + unsafe fn stop_for_vm_teardown(this: *mut c_void) { + // SAFETY: recorded via `Source::set_owner` by this live writer; cleared + // when the writer closes (source taken → uv_close → off the list). + unsafe { (*this.cast::()).close() }; + } + + /// Close the source without invoking `Parent::on_close` — for `Drop`, where + /// the parent is mid-teardown. Error paths use `close()` so the parent + /// still observes `on_close`. + fn close_without_reporting(&mut self) { + if self.source().is_some() { + self.set_closed_without_reporting(true); + // Last: `close()` may drop the parent's final ref and free `self`. + self.close(); + } + } fn get_fd(&self) -> Fd { let Some(pipe) = self.source() else { @@ -1199,13 +1219,17 @@ pub trait BaseWindowsPipeWriter { } } + /// Also the single point where this writer records itself as the owner a + /// VM teardown stops its pipe/tty through: every way a source is installed + /// (`start`, `start_with_pipe`, `set_pipe`, `start_sync`, `start_with_file`) + /// funnels through here. fn set_parent(&mut self, parent: *mut Self::Parent) { self.set_parent_ptr(parent); if !self.is_done() { // raw self-ptr first to dodge the immutable-then-mutable conflict let self_ptr = core::ptr::from_mut(self).cast::(); - if let Some(pipe) = self.source_mut().as_mut() { - pipe.set_data(self_ptr); + if let Some(source) = self.source_mut().as_mut() { + source.set_owner(self_ptr, Self::stop_for_vm_teardown); } } } @@ -1368,6 +1392,8 @@ pub struct WindowsBufferedWriter { pub owns_fd: bool, pub(crate) parent: *mut Parent, pub(crate) is_done: bool, + /// Set by `Drop`: the parent is going away, close without `on_close`. + pub(crate) closed_without_reporting: bool, // we use only one write_req, any queued data in outgoing will be flushed after this ends pub(crate) write_req: uv::uv_write_t, pub(crate) write_buffer: uv::uv_buf_t, @@ -1382,6 +1408,7 @@ impl Default for WindowsBufferedWriter BaseWindowsPipeWriter for WindowsBuffe } fn on_close_source(&mut self) { + if self.closed_without_reporting { + return; + } if Parent::HAS_ON_CLOSE { // SAFETY: parent is BACKREF set via set_parent; valid while writer alive. unsafe { Parent::on_close(self.parent) }; } } + fn closed_without_reporting(&self) -> bool { + self.closed_without_reporting + } + fn set_closed_without_reporting(&mut self, v: bool) { + self.closed_without_reporting = v; + } fn start_with_current_pipe(&mut self) -> sys::Result<()> { debug_assert!(self.source.is_some()); @@ -1946,12 +1982,17 @@ impl BaseWindowsPipeWriter fn on_close_source(&mut self) { self.source = None; if self.closed_without_reporting { - self.closed_without_reporting = false; return; } // SAFETY: parent is BACKREF set via set_parent; valid while writer alive. unsafe { Parent::on_close(self.parent) }; } + fn closed_without_reporting(&self) -> bool { + self.closed_without_reporting + } + fn set_closed_without_reporting(&mut self, v: bool) { + self.closed_without_reporting = v; + } fn start_with_current_pipe(&mut self) -> sys::Result<()> { debug_assert!(self.source.is_some()); @@ -2323,17 +2364,6 @@ impl WindowsStreamingWriter { Self::r(this).last_write_result = WriteResult::Pending(0); } - /// Close the source without invoking `Parent::on_close`. Only `Drop` uses - /// this (the parent is mid-teardown there). Error paths must use `close()` - /// instead so the parent still observes `on_close`. - fn close_without_reporting(&mut self) { - if self.get_fd() != Fd::INVALID { - debug_assert!(!self.closed_without_reporting); - self.closed_without_reporting = true; - self.close(); - } - } - fn write_internal_u8(&mut self, buffer: &[u8], kind: WriteKind) -> WriteResult { if self.is_done { return WriteResult::Done(0); @@ -2486,6 +2516,16 @@ impl WindowsStreamingWriter { } } +#[cfg(windows)] +impl Drop for WindowsBufferedWriter { + fn drop(&mut self) { + // A parent dropping an open writer (e.g. `end()` deferred the close for a + // pending write): hand the handle to libuv like any close, minus the + // report to the parent that is going away. + self.close_without_reporting(); + } +} + #[cfg(windows)] impl Drop for WindowsStreamingWriter { fn drop(&mut self) { diff --git a/src/io/keep_alive.rs b/src/io/keep_alive.rs index 26d1e3be8e8c..b45fbb8a007b 100644 --- a/src/io/keep_alive.rs +++ b/src/io/keep_alive.rs @@ -51,15 +51,6 @@ impl KeepAlive { event_loop_ctx.loop_sub_active(1); } - /// From another thread, Prevent a poll from keeping the process alive. - pub fn unref_concurrently(&mut self, vm: EventLoopCtx) { - if self.status != Status::Active { - return; - } - self.status = Status::Inactive; - vm.unref_concurrently(); - } - /// Prevent a poll from keeping the process alive on the next tick. pub fn unref_on_next_tick(&mut self, event_loop_ctx: EventLoopCtx) { if self.status != Status::Active { @@ -90,21 +81,4 @@ impl KeepAlive { pub fn r#ref(&mut self, event_loop_ctx: EventLoopCtx) { self.ref_(event_loop_ctx) } - - /// Allow a poll to keep the process alive. - pub fn ref_concurrently(&mut self, vm: EventLoopCtx) { - if self.status != Status::Inactive { - return; - } - self.status = Status::Active; - vm.ref_concurrently(); - } - - pub fn ref_concurrently_from_event_loop(&mut self, loop_: EventLoopCtx) { - self.ref_concurrently(loop_); - } - - pub fn unref_concurrently_from_event_loop(&mut self, loop_: EventLoopCtx) { - self.unref_concurrently(loop_); - } } diff --git a/src/io/lib.rs b/src/io/lib.rs index b206fd57eb7c..e17d52e9384b 100644 --- a/src/io/lib.rs +++ b/src/io/lib.rs @@ -321,8 +321,6 @@ bun_dispatch::link_interface! { // bearing enum fields. `FilePoll::init` now goes through // `file_polls_ptr()` + `Store::get_init` (write-before-read). fn increment_pending_unref_counter(); - fn ref_concurrently(); - fn unref_concurrently(); fn after_event_loop_callback() -> Option; fn set_after_event_loop_callback( cb: Option, diff --git a/src/io/source.rs b/src/io/source.rs index 60f3db1d23f9..bb230336c72b 100644 --- a/src/io/source.rs +++ b/src/io/source.rs @@ -68,6 +68,12 @@ pub struct File { /// When true, file will close itself when the current operation completes. pub(crate) close_after_operation: bool, + + /// A read still in flight when its reader let go of this file (`iov` + /// points into it): the reader's buffer, kept alive here until the + /// detached completion frees the Box, so the pending ReadFile never lands + /// in freed memory. + pub(crate) orphaned_read_buf: Vec, } #[repr(u8)] @@ -94,6 +100,7 @@ impl Default for File { file: 0, state: FileState::Deinitialized, close_after_operation: false, + orphaned_read_buf: Vec::new(), } } } @@ -291,6 +298,44 @@ impl Source { } } + /// `owner` (a reader or writer) now drives this source: point the uv + /// handle's `data` at it and record it as the one a thread teardown closes + /// the source through (`uv::open_handles`). A file is listed only by a + /// reader (`WindowsBufferedReader::set_source`); for anything else the + /// file arm just sets `data`. + pub fn set_owner( + &mut self, + owner: *mut c_void, + close_via_owner: uv::open_handles::CloseViaOwner, + ) { + self.set_data(owner); + match self { + Source::Pipe(pipe) => uv::open_handles::set_owner( + core::ptr::from_mut::(pipe).cast(), + owner, + Some(close_via_owner), + ), + Source::Tty(tty) => { + uv::open_handles::set_owner(tty.as_ptr().cast(), owner, Some(close_via_owner)) + } + Source::SyncFile(file) | Source::File(file) => uv::open_handles::set_file_owner( + core::ptr::from_mut::(file).cast(), + owner, + close_via_owner, + ), + } + } + + /// The boxed `File`'s address — the key a reader lists it under. + pub fn file_key(&mut self) -> Option<*mut c_void> { + match self { + Source::SyncFile(file) | Source::File(file) => { + Some(core::ptr::from_mut::(file).cast()) + } + _ => None, + } + } + pub fn ref_(&mut self) { match self { Source::Pipe(pipe) => pipe.ref_(), diff --git a/src/js/internal/worker/messaging.ts b/src/js/internal/worker/messaging.ts index 8281a0c986e4..7b56cb37bd21 100644 --- a/src/js/internal/worker/messaging.ts +++ b/src/js/internal/worker/messaging.ts @@ -217,7 +217,7 @@ function setupMainThreadPort(port: any, setEntryEvaluatedHook: (hook: () => void mainThreadPort.on("message", handleMessageFromMainThreadGated); // Stored on ZigGlobalObject (WriteBarrier), not on globalThis, so user code - // can't observe or clobber it. WebWorker__dispatchOnline calls it once. + // can't observe or clobber it. WebWorker__entrySettled calls it once. setEntryEvaluatedHook(() => { entryEvaluated = true; const pending = pendingMainPortMessages; diff --git a/src/js/node/inspector.ts b/src/js/node/inspector.ts index c03dd8c27336..98503a5e4ea8 100644 --- a/src/js/node/inspector.ts +++ b/src/js/node/inspector.ts @@ -423,6 +423,25 @@ class Session extends EventEmitter { // JSC has no counter-reset API, so subtract the previous take instead. #coverageBaseline: Map = new Map(); + // Report each block's count relative to the baseline, and make the raw + // counts the new baseline. + #rebaseCoverage(scripts: any[]) { + const baseline = this.#coverageBaseline; + for (const script of scripts) { + for (const block of script.blocks) { + const key = `${script.scriptId}:${block[0]}:${block[1]}`; + const raw = block[2]; + block[2] = Math.max(0, raw - (baseline.$get(key) ?? 0)); + baseline.$set(key, raw); + } + } + } + + #snapshotCoverageBaseline() { + const scripts = collectCoverageScripts(); + if (!(scripts instanceof Error)) this.#rebaseCoverage(scripts); + } + connect() { if (this.#connected) { throw $ERR_INSPECTOR_ALREADY_CONNECTED(); @@ -571,7 +590,12 @@ class Session extends EventEmitter { } this.#preciseCoverageCallCount = !!(params as any)?.callCount; this.#preciseCoverageDetailed = !!(params as any)?.detailed; + // Counts start from zero here: the VM's profiler is never torn down once + // enabled (see JSInspectorProfiler.cpp), so whatever it accumulated + // before this start — an earlier session, or the window since a + // stopPreciseCoverage — becomes the baseline the next take subtracts. this.#coverageBaseline.$clear(); + this.#snapshotCoverageBaseline(); // CDP: monotonic seconds since an arbitrary origin (V8 uses TimeTicks). return { timestamp: performance.now() / 1000 }; } @@ -595,15 +619,7 @@ class Session extends EventEmitter { // second take reports the delta. JSC has no counter reset, so subtract // the previous take's raw block counts (function-level call counts are // derived from the entry block, so they follow automatically). - const baseline = this.#coverageBaseline; - for (const script of scripts) { - for (const block of script.blocks) { - const key = `${script.scriptId}:${block[0]}:${block[1]}`; - const raw = block[2]; - block[2] = Math.max(0, raw - (baseline.$get(key) ?? 0)); - baseline.$set(key, raw); - } - } + this.#rebaseCoverage(scripts); return { result: buildScriptCoverageList(scripts, this.#preciseCoverageCallCount, this.#preciseCoverageDetailed), timestamp: performance.now() / 1000, diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index f8b269dd56ae..5fa6aa8707f8 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -79,6 +79,7 @@ const { 8: _markAsUncloneable, 9: _setEntryEvaluatedHook, 10: _isNodeWorker, + 11: _setParentPort, } = $cpp("Worker.cpp", "createNodeWorkerThreadsBinding") as [ unknown, number, @@ -91,6 +92,7 @@ const { (value: unknown) => void, (hook: () => void) => void, boolean, + (port: MessagePort) => void, ]; type NodeWorkerOptions = import("node:worker_threads").WorkerOptions; @@ -319,6 +321,10 @@ let resourceLimits = {}; const BUN_WORKER_STDIO_KEY = "@@bunWorkerThreadsStdio"; const BUN_WORKER_MESSAGING_KEY = "@@bunWorkerThreadsMessaging"; +// The worker's `parentPort`: port2 of a channel whose port1 is the parent +// Worker's public port (node's kPublicPort). Rides inside workerData like the +// stdio and control ports. +const BUN_WORKER_PARENT_PORT_KEY = "@@bunWorkerThreadsParentPort"; // Captured stdio rides a dedicated MessageChannel per stream with node's flow // control (lib/internal/worker/io.js): the writer posts an array of chunks @@ -721,6 +727,10 @@ let workerData = unpackJSTransferables(_workerData); let threadId = _threadId; // node: main-thread and unspecified-worker name are both "" (trimmed). const threadName = isMainThread ? "" : (_threadName ?? ""); +// Set below from the transferred port for node workers; a raw `new Worker()` +// (web) that loads this module has no parent port pair, so it keeps the +// global-scope facade. +let parentPort: MessagePort | null = null; // postMessageToThread (Node 22+): the Worker ctor always smuggles a control // MessagePort to the worker by wrapping workerData; unwrap it here. const messaging = require("internal/worker/messaging"); @@ -734,20 +744,35 @@ if ( _isNodeWorker && workerData && typeof workerData === "object" && - (BUN_WORKER_STDIO_KEY in workerData || BUN_WORKER_MESSAGING_KEY in workerData) + (BUN_WORKER_STDIO_KEY in workerData || + BUN_WORKER_MESSAGING_KEY in workerData || + BUN_WORKER_PARENT_PORT_KEY in workerData) ) { const stdioPorts = workerData[BUN_WORKER_STDIO_KEY]; const controlPort = workerData[BUN_WORKER_MESSAGING_KEY]; + const transferredParentPort = workerData[BUN_WORKER_PARENT_PORT_KEY]; workerData = workerData.data; if (stdioPorts) setupWorkerStdio(stdioPorts); if (controlPort) messaging.setupMainThreadPort(controlPort, _setEntryEvaluatedHook); + // A real MessagePort entangled with the parent Worker's public port: adding a + // 'message' listener starts it and keeps this thread alive; close()/unref()/ + // removing the listeners let the thread exit — node's parentPort lifecycle. + if (transferredParentPort) { + parentPort = transferredParentPort; + // node auto-starts parentPort, but delivery waits until the entry module has + // evaluated (registering it natively is how start() knows to defer). Only + // parentPort receives what the parent posts: `self.onmessage` on the global + // scope is not a channel in a node worker, as in node. The port arrives + // without a loop ref, so an unlistened parentPort does not by itself keep + // the thread alive (a 'message' listener refs it, as in node). + _setParentPort(parentPort); + parentPort.start(); + } } +if (!isMainThread && parentPort === null) parentPort = fakeParentPort(); function receiveMessageOnPort(port: MessagePort) { - let res = _receiveMessageOnPort(port); - if (!res) return undefined; - return { - message: res, - }; + // Native returns node's shape directly: `undefined` when empty, else `{ message }`. + return _receiveMessageOnPort(port); } // TODO: parent port emulation is not complete @@ -771,7 +796,7 @@ function fakeParentPort() { }, }); - const postMessage = $newCppFunction("ZigGlobalObject.cpp", "jsFunctionPostMessage", 1); + const postMessage = $newCppFunction("Worker.cpp", "jsFunctionPostMessage", 1); Object.defineProperty(fake, "postMessage", { value(...args: [any, any]) { return postMessage.$apply(null, args); @@ -824,7 +849,6 @@ function fakeParentPort() { return fake; } -let parentPort: MessagePort | null = isMainThread ? null : fakeParentPort(); // In a node:worker_threads worker, several process operations are unsupported. // Gate on _isNodeWorker so a raw `new globalThis.Worker` that transitively loads @@ -929,6 +953,8 @@ class Worker extends EventEmitter { #exited = false; #stdinPort; #stdoutPort; + // node's kPublicPort: parent end of the parentPort channel. + #publicPort!: MessagePort; #stderrPort; #stdin; #stdout; @@ -1012,7 +1038,17 @@ class Worker extends EventEmitter { const channel = messaging.createMessagingChannel(); portToMain = channel.portToMain; const portToWorker = channel.portToWorker; - const workerDataWrapper: any = { [BUN_WORKER_MESSAGING_KEY]: portToWorker, data: options.workerData }; + // Public port pair (node's kPublicPort / parentPort): worker.postMessage() + // and 'message'/'messageerror' travel over it, so parentPort inside the + // worker is a real MessagePort with node's close/ref/unref semantics. + const publicChannel = new MessageChannel(); + this.#publicPort = publicChannel.port1; + const parentPortForWorker = publicChannel.port2; + const workerDataWrapper: any = { + [BUN_WORKER_MESSAGING_KEY]: portToWorker, + [BUN_WORKER_PARENT_PORT_KEY]: parentPortForWorker, + data: options.workerData, + }; // stdout/stderr always create channels (stdin only when requested), so the // worker always receives a stdio control object. workerDataWrapper[BUN_WORKER_STDIO_KEY] = stdioForWorker; @@ -1023,8 +1059,8 @@ class Worker extends EventEmitter { name: this.#name, workerData: workerDataWrapper, transferList: options.transferList - ? [...options.transferList, portToWorker, ...stdioTransfer] - : [portToWorker, ...stdioTransfer], + ? [...options.transferList, portToWorker, parentPortForWorker, ...stdioTransfer] + : [portToWorker, parentPortForWorker, ...stdioTransfer], }; // env: SHARE_ENV becomes a native boolean flag so it passes native option @@ -1076,11 +1112,27 @@ class Worker extends EventEmitter { require("internal/trace_events").emitWorkerThreadName(options.name, this.#worker.threadId); this.#worker.addEventListener("close", this.#onClose.bind(this), { once: true }); this.#worker.addEventListener("error", this.#onError.bind(this)); - this.#worker.addEventListener("message", this.#onMessage.bind(this)); - this.#worker.addEventListener("messageerror", this.#onMessageError.bind(this)); this.#worker.addEventListener("open", this.#onOpen.bind(this), { once: true, }); + // Messages from parentPort.postMessage() arrive on the public port. Listening + // starts the port. Node's setupPortReferencing: the port counts toward the + // parent's liveness only while this Worker has 'message' listeners (and + // ref()/unref() also touch it, together with the handle). + this.#publicPort.addEventListener("message", this.#onMessage.bind(this)); + this.#publicPort.addEventListener("messageerror", this.#onMessageError.bind(this)); + this.#publicPort.unref(); + const publicPort = this.#publicPort; + this.on("newListener", function (this: Worker, name) { + if (name === "message" && this.listenerCount("message") === 0) publicPort.ref(); + }); + this.on("removeListener", function (this: Worker, name) { + if (name === "message" && this.listenerCount("message") === 0) publicPort.unref(); + }); + // A worker may also use the Web Worker global `postMessage()` / `self.onmessage` + // pair, which travels through the Worker object itself; surface those too. + this.#worker.addEventListener("message", this.#onMessage.bind(this)); + this.#worker.addEventListener("messageerror", this.#onMessageError.bind(this)); if (this.#urlToRevoke) { if (!urlRevokeRegistry) { @@ -1101,13 +1153,15 @@ class Worker extends EventEmitter { } ref() { - // stdio ports are not touched here (node's ref()/unref() only touch the - // handle and the public port); their ref state tracks in-flight I/O. + // node's ref()/unref() touch the handle and the public port; stdio ports' + // ref state tracks in-flight I/O. this.#worker.ref(); + this.#publicPort.ref(); } unref() { this.#worker.unref(); + this.#publicPort.unref(); } get stdin() { @@ -1178,7 +1232,8 @@ class Worker extends EventEmitter { } postMessage(...args: [any, any]) { - return this.#worker.postMessage.$apply(this.#worker, args); + if (this.#exited) return; + return this.#publicPort.postMessage.$apply(this.#publicPort, args); } getHeapSnapshot(options: unknown) { @@ -1287,6 +1342,15 @@ class Worker extends EventEmitter { this.#stdin.destroy(); } this.#stdinPort?.close(); + // node delivers everything the worker posted before it exited ahead of + // 'exit' (kOnExit drains the public port), then closes the port. + { + let entry; + while ((entry = _receiveMessageOnPort(this.#publicPort)) !== undefined) { + this.emit("message", entry.message); + } + this.#publicPort.close(); + } this.#onExitPromise = e.code; this.emit("exit", e.code); } diff --git a/src/js_parser_jsc/Macro.rs b/src/js_parser_jsc/Macro.rs index c2ac041127b2..4d6e8a203f53 100644 --- a/src/js_parser_jsc/Macro.rs +++ b/src/js_parser_jsc/Macro.rs @@ -563,7 +563,7 @@ impl<'a> Run<'a> { let result = vm.run_with_api_lock(|| { macro_callback .call(global, JSValue::ZERO, args) - .unwrap_or_else(|_| global.try_take_exception().unwrap_or(JSValue::ZERO)) + .unwrap_or_else(|_| global.try_take_exception().unwrap_or_default()) }); let mut runner = Run { @@ -838,7 +838,9 @@ impl<'a> Run<'a> { let _ = self.macro_.vm(); let vm = VirtualMachine::get(); - vm.as_mut().wait_for_promise(promise); + if vm.as_mut().wait_for_promise(promise).is_err() { + return Err(MacroError::Js(JsError::Terminated)); + } let promise_result = promise.result(vm.jsc_vm()); let rejected = promise.status() == jsc::js_promise::Status::Rejected; diff --git a/src/js_parser_jsc/expr_jsc.rs b/src/js_parser_jsc/expr_jsc.rs index 75673dd08cd3..1c3adec7ccf8 100644 --- a/src/js_parser_jsc/expr_jsc.rs +++ b/src/js_parser_jsc/expr_jsc.rs @@ -178,7 +178,12 @@ extern "C" fn Bun__JSONRows__wtf8ToJS( ) -> JSValue { // SAFETY: the C++ caller passes a live tape string. let bytes = unsafe { core::slice::from_raw_parts(ptr, len) }; - utf8_bytes_to_js(bytes, global).unwrap_or(JSValue::ZERO) + match utf8_bytes_to_js(bytes, global) { + Ok(value) => value, + // Only the string's to_js can fail here (JSError / JSTerminated): the + // exception is pending and the caller RETURN_IF_EXCEPTIONs on empty. + Err(_) => JSValue::ZERO, + } } /// The whole document under `root` in one call into C++ (keys and short diff --git a/src/jsc/AsyncModule.rs b/src/jsc/AsyncModule.rs index 16783dffcf1e..5bfc14267a0c 100644 --- a/src/jsc/AsyncModule.rs +++ b/src/jsc/AsyncModule.rs @@ -72,6 +72,15 @@ pub struct Queue { pub(crate) scheduled: u32, } +/// 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. +pub struct WakeContext { + pub queue: *mut Queue, + pub loop_handle: crate::LoopHandle, +} + impl Queue { /// Recover the owning VM. /// @@ -99,10 +108,23 @@ impl Queue { // borrow into `VirtualMachine.modules`, never freed by the dispatcher. impl bun_event_loop::Taskable for Queue { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::PollPendingModulesTask; + /// A "poll your pending modules" ping from an install thread: `this` is + /// the VM's own queue; nothing is owned. + unsafe fn release_unrun(_: *mut Self) {} } impl bun_event_loop::Taskable for AsyncModule { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::AsyncModule; + /// A module whose dependencies finished installing but whose fulfilment + /// will not run: undo `done()`'s bookkeeping and drop it (its promise + /// handle, arena and parse result go with the box). + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — the box `done()` queued. + let mut this = unsafe { bun_core::heap::take(this) }; + let vm = VirtualMachine::get().as_mut(); + this.poll_ref.unref(bun_io::js_vm_ctx()); + vm.modules.scheduled -= 1; + } } impl AsyncModule { @@ -352,20 +374,28 @@ impl Queue { }); } + /// `WakeHandler::handler` — runs on install / HTTP-callback threads + /// (`PackageManager::wake_raw`). `ctx` is the [`WakeContext`] registered in + /// `runtime/jsc_hooks.rs`; the VM is reached only through its handle. pub fn on_wake_handler(ctx: *mut c_void, _: *mut c_void) { bun_core::scoped_log!(AsyncModule, "onWake"); - let queue = ctx.cast::(); - let task = ConcurrentTaskItem::create_from(queue); - // SAFETY: runs on thread-pool / HTTP-callback threads (PackageManager::wake_raw) - // where the per-thread `VirtualMachine::get()` singleton is NOT - // installed — using it here would panic. `ctx` was registered as - // `addr_of_mut!((*vm).modules)` from a raw `*mut VirtualMachine` - // (runtime/jsc_hooks.rs), so its provenance covers the whole VM and - // `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); + // SAFETY: `ctx` is the leaked `WakeContext` registered with this handler. + let ctx = unsafe { &*ctx.cast::() }; + 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. + // SAFETY: refused ⇒ we own the task box. + unsafe { drop(bun_core::heap::take(task.as_ptr())) }; + } + } + + /// `WakeHandler::on_dependency_error` context accessor — JS thread. + /// + /// # Safety + /// `ctx` is the leaked `WakeContext` registered in `runtime/jsc_hooks.rs`. + pub unsafe fn queue_from_wake_context(ctx: *mut c_void) -> *mut Queue { + // SAFETY: fn contract. + unsafe { (*ctx.cast::()).queue } } pub fn on_poll(&mut self) { diff --git a/src/jsc/ConcurrentPromiseTask.rs b/src/jsc/ConcurrentPromiseTask.rs deleted file mode 100644 index 051d0517f22a..000000000000 --- a/src/jsc/ConcurrentPromiseTask.rs +++ /dev/null @@ -1,130 +0,0 @@ -use bun_event_loop::ConcurrentTask::{AutoDeinit, ConcurrentTask, TaskTag, Taskable}; -use bun_io::{self as Async, KeepAlive}; -use bun_threading::{IntrusiveWorkTask as _, WorkPoolTask, work_pool::WorkPool}; - -use crate::event_loop::EventLoop; -use crate::js_promise::{JSPromise, Strong as JSPromiseStrong}; -use crate::virtual_machine::VirtualMachine; -use crate::{JSGlobalObject, JsTerminated}; -use bun_ptr::BackRef; - -/// The `Context` type parameter for [`ConcurrentPromiseTask`] must implement this trait: -/// - `run(&mut self)` — performs the work on the thread pool -/// - `then(&mut self, &mut JSPromise)` — resolves the promise with the result on the JS thread -pub trait ConcurrentPromiseTaskContext: Sized { - /// Tag this `ConcurrentPromiseTask` carries when enqueued back onto the - /// JS event loop's concurrent queue (`task_tag::*`). - const TASK_TAG: TaskTag; - - fn run(&mut self); - fn then(&mut self, promise: &mut JSPromise) -> Result<(), JsTerminated>; -} - -/// A generic task that runs work on a thread pool and resolves a JavaScript Promise with the result. -/// This allows CPU-intensive operations to be performed off the main JavaScript thread while -/// maintaining a Promise-based API for JavaScript consumers. -/// -/// The Context type must implement: -/// - `run(*Context)` - performs the work on the thread pool -/// - `then(*Context, jsc.JSPromise)` - resolves the promise with the result on the JS thread -pub struct ConcurrentPromiseTask<'a, Context: ConcurrentPromiseTaskContext> { - // Owned here so dropping the task frees the context. - pub ctx: Box, - pub(crate) task: WorkPoolTask, - /// BACKREF — captured from the JS-thread VM at create time; the VM (and its - /// `EventLoop`) outlives every task scheduled on it. - pub(crate) event_loop: BackRef, - pub promise: JSPromiseStrong, - pub global_this: &'a JSGlobalObject, - pub(crate) concurrent_task: ConcurrentTask, - - // This is a poll because we want it to enter the uSockets loop - // (`ref` is a Rust keyword, hence `ref_`) - pub ref_: KeepAlive, -} - -bun_threading::intrusive_work_task!(['a, Context: ConcurrentPromiseTaskContext] ConcurrentPromiseTask<'a, Context>, task); - -// SAFETY: `ConcurrentPromiseTask` is heap-allocated and only its address crosses -// threads via the intrusive `task` node and the concurrent queue. All access to -// `ctx` / `promise` / `global_this` is sequenced by the work-pool → on_finish → -// run_from_js hand-off; raw pointers are inert. -unsafe impl Send for ConcurrentPromiseTask<'_, C> {} - -impl Taskable for ConcurrentPromiseTask<'_, Context> { - const TAG: TaskTag = Context::TASK_TAG; -} - -impl<'a, Context: ConcurrentPromiseTaskContext> ConcurrentPromiseTask<'a, Context> { - pub fn create_on_js_thread(global_this: &'a JSGlobalObject, value: Box) -> Box { - // `VirtualMachine::get()` returns the JS-thread singleton; the VM and - // its `EventLoop` outlive every task scheduled on it. - let event_loop = BackRef::new(VirtualMachine::get().as_mut().event_loop_shared()); - let mut this = Box::new(Self { - event_loop, - ctx: value, - task: WorkPoolTask { - node: Default::default(), - callback: Self::run_from_thread_pool, - }, - promise: JSPromiseStrong::init(global_this), - global_this, - concurrent_task: ConcurrentTask::default(), - ref_: KeepAlive::default(), - }); - this.ref_.ref_(Async::js_vm_ctx()); - this - } - - pub(crate) unsafe fn run_from_thread_pool(task: *mut WorkPoolTask) { - // SAFETY: only reachable via `WorkPoolTask::callback` (unsafe-fn-ptr - // slot — safe-fn coerces) for the `task` field initialised in - // `create_on_js_thread`; the WorkPool calls back with exactly that - // field, so `from_task_ptr` recovers the live heap `Self` parent, - // exclusively owned by the work pool for this callback's duration. - let this = unsafe { Self::from_task_ptr(task) }; - // SAFETY: `this` is alive for the duration of the thread-pool callback; - // exclusively owned by the work pool at this point. - unsafe { (*this).ctx.run() }; - Self::on_finish(this); - } - - pub fn run_from_js(&mut self) -> Result<(), JsTerminated> { - let promise = self.promise.swap(); - self.ref_.unref(Async::js_vm_ctx()); - - self.ctx.then(promise) - } - - pub fn schedule(&mut self) { - WorkPool::schedule(&raw mut self.task); - } - - fn on_finish(this: *mut Self) { - // SAFETY: only called from `run_from_thread_pool` above with the live - // heap allocation recovered via `from_field_ptr!`; the work pool owns - // it exclusively for this callback's duration. - let event_loop = unsafe { (*this).event_loop }; - // SAFETY: as above. `concurrent_task` is an intrusive field of `*this`; - // `from` re-initializes it in place and returns the same address. - // Passing `this` while a `&mut` to the field is live is sound because - // `from` only stores the pointer (does not dereference it). - let task = core::ptr::NonNull::from(unsafe { - (*this).concurrent_task.from(this, AutoDeinit::ManualDeinit) - }); - // `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); - } - - /// Frees the heap allocation backing this task. - /// - /// # Safety - /// `this` must have been produced by `heap::alloc` (via [`create_on_js_thread`] / - /// the `.manual_deinit` concurrent-task path) and must not be used afterwards. - pub unsafe fn destroy(this: *mut Self) { - // `promise.deinit()` is handled by `JSPromiseStrong: Drop`. - // SAFETY: caller contract above. - drop(unsafe { bun_core::heap::take(this) }); - } -} diff --git a/src/jsc/CppTask.rs b/src/jsc/CppTask.rs index 7d3f74544b10..e113524fd5b4 100644 --- a/src/jsc/CppTask.rs +++ b/src/jsc/CppTask.rs @@ -1,15 +1,13 @@ -use core::ptr::NonNull; - -use crate::{JSGlobalObject, JsResult, VirtualMachineRef as VirtualMachine}; +use crate::{JSGlobalObject, JsResult}; use bun_event_loop::{TaskTag, Taskable, task_tag}; use bun_threading::work_pool::{Task as WorkPoolTask, WorkPool}; -#[allow(improper_ctypes)] // VirtualMachine is opaque to C++; passed as `void*` +#[allow(improper_ctypes)] // `Shared` is opaque to C++ (`BunVmHandleRef`) unsafe extern "C" { fn Bun__EventLoopTaskNoContext__performTask(task: *mut EventLoopTaskNoContext); - safe fn Bun__EventLoopTaskNoContext__createdInBunVm( + safe fn Bun__EventLoopTaskNoContext__vmHandle( task: &EventLoopTaskNoContext, - ) -> *mut VirtualMachine; + ) -> *const crate::vm_handle::Shared; } bun_opaque::opaque_ffi! { @@ -19,6 +17,15 @@ bun_opaque::opaque_ffi! { impl Taskable for CppTask { const TAG: TaskTag = task_tag::CppTask; + /// Delete the `WebCore::EventLoopTask` — its captured `Ref`s drop against + /// the still-live heap. + unsafe fn release_unrun(this: *mut Self) { + unsafe extern "C" { + fn Bun__deleteEventLoopTask(task: *mut CppTask); + } + // SAFETY: fn contract; every CppTask payload is a heap EventLoopTask. + unsafe { Bun__deleteEventLoopTask(this) } + } } impl CppTask { @@ -47,13 +54,12 @@ impl EventLoopTaskNoContext { unsafe { Bun__EventLoopTaskNoContext__performTask(this) } } - /// Get the VM that created this task. `VirtualMachine` is process-lifetime - /// (PORTING.md §Global mutable state), so a [`BackRef`] is the right - /// non-owning handle: callers project `&VirtualMachine` via `Deref` and - /// route mutation through the VM's safe interior accessors (e.g. - /// `event_loop_shared()`). - pub(crate) fn get_vm(&self) -> Option> { - NonNull::new(Bun__EventLoopTaskNoContext__createdInBunVm(self)).map(bun_ptr::BackRef::from) + /// 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)) } } } @@ -73,14 +79,15 @@ impl ConcurrentCppTask { 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. - let maybe_vm = EventLoopTaskNoContext::opaque_ref(cpp_task).get_vm(); + // 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); // 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. unsafe { EventLoopTaskNoContext::run(cpp_task) }; - if let Some(vm) = maybe_vm { - vm.event_loop_shared().unref_concurrently(); - } + handle.unref_keep_alive(crate::LoopKind::Regular); } } @@ -89,9 +96,9 @@ extern "C" fn ConcurrentCppTask__createAndRun(cpp_task: *mut EventLoopTaskNoCont crate::mark_binding!(); // `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(); - } + EventLoopTaskNoContext::opaque_ref(cpp_task) + .vm_handle() + .ref_keep_alive(crate::LoopKind::Regular); WorkPool::schedule_new(ConcurrentCppTask { cpp_task, workpool_task: WorkPoolTask::default(), diff --git a/src/jsc/Debugger.rs b/src/jsc/Debugger.rs index 6d369838d42b..da6c7a0aa0a1 100644 --- a/src/jsc/Debugger.rs +++ b/src/jsc/Debugger.rs @@ -690,7 +690,7 @@ pub fn did_connect() { } // ────────────────────────────────────────────────────────────────────────── -// AsyncTaskTracker — stable surface (used by WorkTask / event_loop). +// AsyncTaskTracker — stable surface (used by pool jobs / event_loop). // ────────────────────────────────────────────────────────────────────────── #[derive(Debug, Default, Copy, Clone)] diff --git a/src/jsc/EventLoopHandle.rs b/src/jsc/EventLoopHandle.rs index e0506a03d27f..ee88a7adce76 100644 --- a/src/jsc/EventLoopHandle.rs +++ b/src/jsc/EventLoopHandle.rs @@ -11,6 +11,4 @@ //! path compiling. All behaviour lives in the lower crate; nothing here owns //! logic. -pub use bun_event_loop::any_event_loop::{ - EnteredEventLoop, EventLoopHandle, EventLoopTask, EventLoopTaskPtr, -}; +pub use bun_event_loop::any_event_loop::{EnteredEventLoop, EventLoopHandle, EventLoopTask}; diff --git a/src/jsc/JSCScheduler.rs b/src/jsc/JSCScheduler.rs index b835f2bbae5b..05d553ac6ddb 100644 --- a/src/jsc/JSCScheduler.rs +++ b/src/jsc/JSCScheduler.rs @@ -1,8 +1,6 @@ -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! { @@ -12,6 +10,12 @@ bun_opaque::opaque_ffi! { impl Taskable for JSCDeferredWorkTask { const TAG: TaskTag = task_tag::JSCDeferredWorkTask; + /// A cross-thread Atomics.notify / Wasm / FinalizationRegistry completion: + /// delete the C++ job (its `Ref` drops before ~VM). + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract; heap-allocated by JSCTaskScheduler::onScheduleWorkSoon. + unsafe { Bun__deleteDeferredWorkTask(this) } + } } unsafe extern "C" { @@ -19,9 +23,20 @@ unsafe extern "C" { // via `UnsafeCell`); `&mut` is ABI-identical to a non-null `*mut` and the // C++ side consuming it is interior to the opaque cell. safe fn Bun__runDeferredWork(task: &mut JSCDeferredWorkTask); + fn Bun__deleteDeferredWorkTask(task: *mut JSCDeferredWorkTask); } impl JSCDeferredWorkTask { + /// Delete the C++ job without running it (its ticket is cancelled with the + /// DeferredWorkTimer at VM teardown). + /// + /// # Safety + /// `this` is the job handed over by `onScheduleWorkSoon`, not yet run. + pub unsafe fn destroy(this: *mut Self) { + // SAFETY: fn contract. + unsafe { Bun__deleteDeferredWorkTask(this) }; + } + pub fn run(&mut self) -> Result<(), JsTerminated> { // SAFETY: `VirtualMachine::get()` returns the live per-thread VM; `global` is // initialized during VM startup and remains valid for the VM's lifetime. @@ -36,29 +51,26 @@ impl JSCDeferredWorkTask { } } +/// JSC helper threads (DeferredWorkTimer): deliver a deferred-work job to the +/// VM's loop, or run its release path here if the VM is gone. #[unsafe(no_mangle)] -extern "C" fn Bun__eventLoop__incrementRefConcurrently(jsc_vm: &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(); - if delta > 0 { - event_loop.ref_concurrently(); - } else { - event_loop.unref_concurrently(); - } -} - -#[unsafe(no_mangle)] -extern "C" fn Bun__queueJSCDeferredWorkTaskConcurrently( - jsc_vm: &VirtualMachine, +unsafe extern "C" fn Bun__queueJSCDeferredWorkTaskConcurrently( + r: *const crate::vm_handle::Shared, 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(); + // SAFETY: C++ passes the reference its JSVMClientData holds. + let handle = unsafe { crate::VmHandle::borrow_ref(r) }; // `create_from` heap-allocates with the auto-delete bit set. - loop_.enqueue_task_concurrent(ConcurrentTask::create_from(task)); + let ct = ConcurrentTask::create_from(task); + if let crate::vm_handle::Posted::Refused(ct) = handle.post(crate::LoopKind::Regular, ct) { + // SAFETY: refused ⇒ we own the ConcurrentTask box; the C++ job's ticket + // was already cancelled by the VM teardown (DeferredWorkTimer is shut + // down before ~VM), so dropping the job pointer here loses nothing. + drop(unsafe { bun_core::heap::take(ct.as_ptr()) }); + // SAFETY: `task` is the C++ job handed over for exactly one run/destroy. + unsafe { JSCDeferredWorkTask::destroy(task) }; + } } /// # Safety diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index 8760daf7abbc..032eacd0c94e 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -857,8 +857,8 @@ impl JSGlobalObject { pub fn queue_microtask(&self, function: JSValue, args: &[JSValue]) { self.queue_microtask_job( function, - args.first().copied().unwrap_or(JSValue::ZERO), - args.get(1).copied().unwrap_or(JSValue::ZERO), + args.first().copied().unwrap_or_default(), + args.get(1).copied().unwrap_or_default(), ); } @@ -1103,13 +1103,13 @@ impl JSGlobalObject { /// C++ shim into Rust callers (905 out-of-line `callq` sites in the /// release binary), and /// the FFI result is provably the same singleton — debug-asserted below - /// and in [`Self::bun_vm`]. Same-thread callers only; cross-thread paths - /// must use [`Self::bun_vm_concurrently`]. + /// and in [`Self::bun_vm`]. JS thread only; another thread reaches a VM + /// through its [`VmHandle`](crate::VmHandle), never through a global. #[inline] pub fn bun_vm_ptr(&self) -> *mut VirtualMachine { debug_assert!( self.bun_vm_unsafe() == VirtualMachine::get_mut_ptr().cast::(), - "bun_vm_ptr called off the JS thread; use bun_vm_concurrently", + "bun_vm_ptr called off the JS thread", ); VirtualMachine::get_mut_ptr() } @@ -1132,8 +1132,7 @@ impl JSGlobalObject { /// Reads the thread-local directly instead of calling /// `JSC__JSGlobalObject__bunVM` — cross-language LTO does not inline the /// C++ shim, and the two are address-equal by construction (asserted in - /// debug builds). Same-thread callers only; cross-thread paths must use - /// [`Self::bun_vm_concurrently`]. + /// debug builds). JS thread only (see [`Self::bun_vm_ptr`]). #[inline] pub fn bun_vm(&self) -> &'static VirtualMachine { #[cfg(debug_assertions)] @@ -1152,11 +1151,6 @@ impl JSGlobalObject { VirtualMachine::get() } - /// We can't do the threadlocal check when queued from another thread - pub fn bun_vm_concurrently(&self) -> *mut VirtualMachine { - self.bun_vm_unsafe().cast::() - } - pub fn handle_rejected_promises(&self) { // JSC__JSGlobalObject__handleRejectedPromises catches and reports its // own exceptions; the only thing that escapes is a TerminationException diff --git a/src/jsc/JSPromise.rs b/src/jsc/JSPromise.rs index f36311af6892..98ed5f3ade03 100644 --- a/src/jsc/JSPromise.rs +++ b/src/jsc/JSPromise.rs @@ -83,8 +83,19 @@ impl Strong { global: &JSGlobalObject, val: JsResult, ) -> Result<(), JsTerminated> { - let val = val.unwrap_or_else(|_| global.try_take_exception().unwrap()); - self.swap().reject(global, Ok(val)) + self.swap().reject(global, val) + } + + /// The one way native code hands a conversion outcome to script: `Ok` + /// resolves, `Err` rejects with the exception the failed conversion left + /// pending (see [`JSPromise::resolve`]). Prefer this over + /// `resolve(v.unwrap_or(..))`. + pub fn settle( + &mut self, + global: &JSGlobalObject, + val: JsResult, + ) -> Result<(), JsTerminated> { + self.swap().settle(global, val) } /// Like `reject` but first attaches async stack frames from this promise's @@ -107,6 +118,17 @@ impl Strong { self.swap().resolve(global, val) } + /// [`settle`](Self::settle) from a native completion at the top of the + /// event loop (drains microtasks when the scope exits). + pub fn settle_task( + &mut self, + global: &JSGlobalObject, + val: JsResult, + ) -> Result<(), JsTerminated> { + let _guard = VirtualMachine::get().enter_event_loop_scope(); + self.settle(global, val) + } + /// Like `resolve`, except it drains microtasks at the end of the current event loop iteration. pub fn resolve_task( &mut self, @@ -161,7 +183,7 @@ impl Strong { } pub fn value_or_empty(&self) -> JSValue { - self.strong.get().unwrap_or(JSValue::ZERO) + self.strong.get().unwrap_or_default() } pub fn has_value(&self) -> bool { @@ -319,30 +341,76 @@ impl JSPromise { /// Fulfill an existing promise with the value. /// The value can be another Promise. /// If you want to create a new Promise that is already resolved, see `resolved_promise_value`. + // ── the native → promise boundary ───────────────────────────────────── + // + // Every settlement native code performs funnels through `resolve` / + // `reject` below (the `Strong` methods delegate here), so the rule lives + // here once: an empty `JSValue` is never a value — it means the producer's + // JS conversion threw and left the exception pending (a termination request + // landing mid-conversion is the common case). It becomes "reject with that + // exception", which itself yields to a termination (`Err(JSTerminated)`, + // exception left pending to unwind the caller). + // + // Whether a *completion* should reach script at all once the VM is + // stopping is decided where completions enter (`job::complete_erased`, + // the event loop's callback entry), not here: a host function that is + // still running script settles promises normally until its own trap fires. + pub fn resolve(&mut self, global: &JSGlobalObject, value: JSValue) -> Result<(), JsTerminated> { + if value.is_empty() { + debug_assert!( + global.has_exception(), + "resolve() with an empty JSValue and no pending exception" + ); + return self.reject(global, Err(JsError::Thrown)); + } // `[[ZIG_EXPORT(check_slow)]]` crate::cpp::JSC__JSPromise__resolve(self, global, value) .map_err(|_| JsTerminated::JSTerminated) } + /// See [`Strong::settle`]. + pub fn settle( + &mut self, + global: &JSGlobalObject, + value: JsResult, + ) -> Result<(), JsTerminated> { + match value { + Ok(v) => self.resolve(global, v), + Err(e) => self.reject(global, Err(e)), + } + } + pub fn reject( &mut self, global: &JSGlobalObject, value: JsResult, ) -> Result<(), JsTerminated> { let err = match value { + Ok(v) if v.is_empty() => { + debug_assert!( + global.has_exception(), + "reject() with an empty JSValue and no pending exception" + ); + return self.reject(global, Err(JsError::Thrown)); + } Ok(v) => v, // We can't use `global.take_exception()` because it throws an // out-of-memory error when we instead need to take the exception. Err(JsError::OutOfMemory) => global.create_out_of_memory_error(), - Err(JsError::Terminated) => return Ok(()), - Err(_) => 'err: { + Err(JsError::Terminated) => return Err(JsTerminated::JSTerminated), + Err(JsError::Thrown) => { let Some(exception) = global.try_take_exception() else { panic!( "A JavaScript exception was thrown, but it was cleared before it could be read." ); }; - break 'err exception.to_error().unwrap_or(exception); + // A termination request that landed in the producer's conversion + // is not an outcome to report; it stays pending and unwinds us. + if exception.is_termination_exception() { + return Err(JsTerminated::JSTerminated); + } + exception.to_error().unwrap_or(exception) } }; @@ -356,6 +424,10 @@ impl JSPromise { global: &JSGlobalObject, value: JSValue, ) -> Result<(), JsTerminated> { + if value.is_empty() { + self.set_handled(); + return self.reject(global, Ok(value)); + } // `[[ZIG_EXPORT(check_slow)]]` crate::cpp::JSC__JSPromise__rejectAsHandled(self, global, value) .map_err(|_| JsTerminated::JSTerminated) diff --git a/src/jsc/JSSecrets.rs b/src/jsc/JSSecrets.rs index e1eb162db53d..4c58fc528526 100644 --- a/src/jsc/JSSecrets.rs +++ b/src/jsc/JSSecrets.rs @@ -1,4 +1,4 @@ -use crate::{AnyTaskJob, AnyTaskJobCtx, JSGlobalObject, JSValue, JsResult, Strong}; +use crate::{JSGlobalObject, JSValue, JsResult, Strong}; // Opaque pointer to C++ SecretsJobOptions struct bun_opaque::opaque_ffi! { pub struct SecretsJobOptions; } @@ -18,49 +18,55 @@ unsafe extern "C" { fn Bun__SecretsJobOptions__deinit(ctx: *mut SecretsJobOptions); } -pub(crate) struct SecretsCtx { - ctx: *mut SecretsJobOptions, - promise: Strong, +/// The C++ `SecretsJobOptions` a job owns; plain data, freed wherever the job ends. +struct SecretsOptions(*mut SecretsJobOptions); +// SAFETY: an owned C++ heap object with no thread affinity. +unsafe impl Send for SecretsOptions {} +impl Drop for SecretsOptions { + fn drop(&mut self) { + // SAFETY: the pointer C++ handed to `Bun__Secrets__scheduleJob`; freed once, here. + unsafe { Bun__SecretsJobOptions__deinit(self.0) }; + } } -impl AnyTaskJobCtx for SecretsCtx { - fn run(&mut self, global: *mut JSGlobalObject) { - // `ctx` is a valid C++ SecretsJobOptions* held alive until Drop; - // `global` is the creating VM's global pointer. Both are `opaque_ffi!` - // ZST handles, so `opaque_mut`/`opaque_ref` are the centralised - // zero-byte deref proofs (panic on null). - Bun__SecretsJobOptions__runTask( - SecretsJobOptions::opaque_mut(self.ctx), - JSGlobalObject::opaque_ref(global), - ); +/// `Bun.secrets.{get,set,delete}` off the JS thread. +pub(crate) struct SecretsJob { + options: SecretsOptions, + global: crate::JsPtr, +} + +impl crate::JobContext for SecretsJob { + type OffThread = Self; + type Js = Strong; + + fn run( + this: &mut Self, + vm: &crate::vm_handle::Borrow, + done: crate::Completion, + ) -> Option> { + // SAFETY: the creating global, alive under the borrow; C++ only threads it through. + let global = unsafe { this.global.under_borrow(vm) }; + Bun__SecretsJobOptions__runTask(SecretsJobOptions::opaque_mut(this.options.0), global); + Some(done) } - fn then(&mut self, global: &JSGlobalObject) -> JsResult<()> { - let promise = self.promise.get(); - if promise.is_empty() { - return Ok(()); - } + fn then(this: Self, promise: Strong, cx: &crate::JsThread<'_>) -> JsResult<()> { + let global = cx.global(); // `Bun__SecretsJobOptions__runFromJS` opens a `DECLARE_THROW_SCOPE` and // returns via `RELEASE_AND_RETURN`, which simulates a throw to the parent // scope under `BUN_JSC_validateExceptionChecks=1`. Without an enclosing // scope here, `drainMicrotasks`'s `TopExceptionScope` ctor asserts on the // unchecked simulated throw — same shape as `JSCDeferredWorkTask::run`. crate::validation_scope!(scope, global); - Bun__SecretsJobOptions__runFromJS(SecretsJobOptions::opaque_mut(self.ctx), global, promise); + Bun__SecretsJobOptions__runFromJS( + SecretsJobOptions::opaque_mut(this.options.0), + global, + promise.get(), + ); scope.assert_no_exception_except_termination() } } -impl Drop for SecretsCtx { - fn drop(&mut self) { - // SAFETY: `ctx` is the C++ SecretsJobOptions* passed to `create`; C++ side owns cleanup. - unsafe { Bun__SecretsJobOptions__deinit(self.ctx) }; - // `promise: Strong` drops automatically. - } -} - -type SecretsJob = AnyTaskJob; - // Helper function for C++ to call with opaque pointer #[unsafe(no_mangle)] extern "C" fn Bun__Secrets__scheduleJob( @@ -68,12 +74,14 @@ extern "C" fn Bun__Secrets__scheduleJob( options: *mut SecretsJobOptions, promise: JSValue, ) { - SecretsJob::create_and_schedule( - global, - SecretsCtx { - ctx: options, - promise: Strong::create(promise, global), + let cx = global.js_thread(); + crate::Job::::schedule( + &cx, + SecretsJob { + options: SecretsOptions(options), + // SAFETY: the creating global outlives every borrow of its VM. + global: unsafe { crate::JsPtr::new(core::ptr::NonNull::from(global)) }, }, - ) - .expect("SecretsCtx::init is infallible"); + Strong::create(promise, global), + ); } diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index 1ffefc5fa1bc..af72fb3c985a 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -579,12 +579,13 @@ impl JSValue { crate::mark_binding!(); host_fn::from_js_host_call(global, || JSBuffer__bufferFromLength(global, len as i64)) } - pub fn create_buffer(global: &JSGlobalObject, slice: &mut [u8]) -> JSValue { - // Wraps `JSBuffer__bufferFromPointerAndLengthAndDeinit` - // with `MarkedArrayBuffer_deallocator` (or null for empty slices). + /// Wraps `slice` (mimalloc-owned; ownership moves to JSC, freed via + /// `MarkedArrayBuffer_deallocator`) in a Node `Buffer`. Fails like any JS + /// allocation: OOM, or a termination request landing in it. + pub fn create_buffer(global: &JSGlobalObject, slice: &mut [u8]) -> JsResult { // SAFETY: `global` is live; slice ptr/len describe a valid range whose // ownership is transferred to JSC (freed via the deallocator). - unsafe { + host_fn::from_js_host_call(global, || unsafe { JSBuffer__bufferFromPointerAndLengthAndDeinit( global, slice.as_mut_ptr(), @@ -596,13 +597,13 @@ impl JSValue { Some(MarkedArrayBuffer_deallocator) }, ) - } + }) } /// Take ownership of a mimalloc-backed `Box<[u8]>` and wrap it in a Node /// `Buffer` without copying. Ownership transfers to JSC; freed via /// `MarkedArrayBuffer_deallocator` on GC. Prefer this over `Box::leak` + /// [`JSValue::create_buffer`] so the FFI hand-off is explicit at call sites. - pub fn create_buffer_from_box(global: &JSGlobalObject, bytes: Box<[u8]>) -> JSValue { + pub fn create_buffer_from_box(global: &JSGlobalObject, bytes: Box<[u8]>) -> JsResult { let len = bytes.len(); // `into_raw` (not `leak`) — this is an FFI ownership transfer, paired // with `mi_free` in `MarkedArrayBuffer_deallocator`. An empty @@ -611,7 +612,7 @@ impl JSValue { let ptr = bun_core::heap::into_raw(bytes).cast::(); // SAFETY: `global` is live; `ptr`/`len` describe the just-released // mimalloc allocation whose ownership is transferred to JSC. - unsafe { + host_fn::from_js_host_call(global, || unsafe { JSBuffer__bufferFromPointerAndLengthAndDeinit( global, ptr, @@ -623,7 +624,7 @@ impl JSValue { Some(MarkedArrayBuffer_deallocator) }, ) - } + }) } /// `JSValue.createBufferWithCtx` — wrap a foreign-owned byte /// range in a Node `Buffer`, transferring ownership to JS. `free(ctx, ptr)` @@ -638,11 +639,11 @@ impl JSValue { bytes: core::ptr::NonNull<[u8]>, ctx: *mut c_void, free: unsafe extern "C" fn(*mut c_void, *mut c_void), - ) -> JSValue { + ) -> JsResult { let len = bytes.len(); // SAFETY: `global` is live; `bytes` describes a valid range whose // ownership transfers to JSC and is released via `free` on collection. - unsafe { + host_fn::from_js_host_call(global, || unsafe { JSBuffer__bufferFromPointerAndLengthAndDeinit( global, bytes.as_ptr().cast::(), @@ -650,7 +651,7 @@ impl JSValue { ctx, Some(free), ) - } + }) } pub fn from_date_number(global: &JSGlobalObject, value: f64) -> JSValue { JSC__JSValue__dateInstanceFromNumber(global, value) diff --git a/src/jsc/PosixSignalHandle.rs b/src/jsc/PosixSignalHandle.rs index c4ae966fabc8..436ca7bcbc4a 100644 --- a/src/jsc/PosixSignalHandle.rs +++ b/src/jsc/PosixSignalHandle.rs @@ -147,6 +147,8 @@ pub struct PosixSignalTask; impl Taskable for PosixSignalTask { const TAG: bun_event_loop::TaskTag = task_tag::PosixSignalTask; + /// `this` packs the signal number; nothing is owned. + unsafe fn release_unrun(_: *mut Self) {} } unsafe extern "C" { diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index 016b9bd524a2..609a8c9b87f1 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -220,6 +220,9 @@ impl Default for RuntimeTranspilerStore { impl Taskable for RuntimeTranspilerStore { const TAG: TaskTag = task_tag::RuntimeTranspilerStore; + /// The "drain my finished jobs" ping owns nothing (`this` is the VM's + /// store); the jobs themselves are released by `release_queued_jobs_for_teardown`. + unsafe fn release_unrun(_: *mut Self) {} } impl RuntimeTranspilerStore { @@ -232,6 +235,28 @@ impl RuntimeTranspilerStore { // Note: takes `NonNull` rather than `&mut` for `event_loop`/`vm` // because `&mut self` already aliases `vm.transpiler_store` (this `Self` is // a field of `VirtualMachine`). Field-level derefs only. + /// VM teardown (JS thread, heap alive, script forbidden, embedded work + /// waited for so no job is mid-flight): jobs whose completion will not run — + /// queued after the last tick, or posted after `close()` began — release + /// their source, log and module promise here instead of running. + pub fn release_queued_jobs_for_teardown(&mut self) { + let batch = self.queue.pop_batch(); + let mut iter = batch.iterator(); + loop { + let job = iter.next(); + if job.is_null() { + break; + } + // SAFETY: a live job popped from the intrusive queue; this thread + // owns it now (its worker-thread part finished before `close()`). + unsafe { + (*job).promise.deinit(); + (*job).reset_for_pool(); + self.store.put(job); + } + } + } + pub fn run_from_js_thread( &mut self, event_loop: NonNull, @@ -321,6 +346,7 @@ impl RuntimeTranspilerStore { global_this: BackRef::new(global_object), non_threadsafe_referrer: OwnedString::new(referrer), vm, + loop_handle: global_object.bun_vm().loop_handle(), log: bun_ast::Log::init(), loader, promise: StrongOptional::create(JSValue::from_cell(promise), global_object), @@ -377,6 +403,10 @@ pub struct TranspilerJob { // raw pointers/BackRefs are used (BACKREF — VM owns the // store and outlives every job). pub(crate) vm: *mut VirtualMachine, + /// The pool thread runs this job under `loop_handle.borrow()`: the job's + /// own slot, the transpiler it copies and the store queue it pushes to are + /// all VM-owned, and the VM's teardown waits for the borrow to end. + pub(crate) loop_handle: crate::LoopHandle, pub global_this: BackRef, pub(crate) fetcher: Fetcher, pub(crate) poll_ref: KeepAlive, @@ -486,16 +516,21 @@ impl TranspilerJob { fn dispatch_to_main_thread(&mut self) { let vm = self.vm; + let loop_handle = self.loop_handle.clone(); // 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)); + // Another thread may free `self` at any time after .push, so we cannot use it any more + // (the handle was cloned out above for exactly this reason). The VM + // waits for embedded work before closing its handle, so this is queued. + let crate::vm_handle::Posted::Queued = + loop_handle.post_task(ConcurrentTask::create_from(transpiler_store)) + else { + unreachable!("VM handle closed with embedded transpile work outstanding"); + }; } fn run_from_js_thread(&mut self) -> JsResult<()> { @@ -559,6 +594,9 @@ impl TranspilerJob { // `EventLoopCtx` vtable; resolve it via the `get_vm_ctx` hook (registered by // `bun_runtime::init`). self.poll_ref.ref_(get_vm_ctx(AllocatorType::Js)); + // The job is a slot inside this VM: counted, so teardown waits for it + // (see `VmHandle::embedded_work_scheduled`). + self.loop_handle.embedded_work_scheduled(); WorkPool::schedule(&raw mut self.work_task); } @@ -566,9 +604,23 @@ impl TranspilerJob { // SAFETY: only reachable via `WorkPoolTask::callback` (unsafe-fn-ptr // slot — safe-fn coerces) for the `work_task` field initialised in // `transpile`; the WorkPool calls back with exactly that field, so - // `from_field_ptr!` recovers the live heap `TranspilerJob` parent. - let this = unsafe { &mut *bun_core::from_field_ptr!(TranspilerJob, work_task, work_task) }; - this.run(); + // `from_field_ptr!` recovers the live `TranspilerJob` parent. + let this = unsafe { bun_core::from_field_ptr!(TranspilerJob, work_task, work_task) }; + // The slot lives inside the VM and the VM waits for us (embedded work), + // so it is alive throughout. Transpile only while the VM is still + // running; either way hand the job back to the JS thread, which + // completes or releases it. + // SAFETY: as above. + let handle = unsafe { (*this).loop_handle.clone() }; + if let Some(_vm) = handle.borrow_if_running() { + // SAFETY: live slot, exclusively ours until dispatched. + unsafe { (*this).run() }; + } else { + // SAFETY: as above. + unsafe { (*this).dispatch_to_main_thread() }; + } + // Last touch of the slot from this thread was the dispatch. + handle.embedded_work_finished(); } fn run(&mut self) { diff --git a/src/jsc/VM.rs b/src/jsc/VM.rs index 41f780c67f5b..f3aee8381018 100644 --- a/src/jsc/VM.rs +++ b/src/jsc/VM.rs @@ -12,7 +12,7 @@ use crate::{JSGlobalObject, JSValue, JsError}; // `holdAPILock` keeps a raw `*mut c_void` ctx (opaque round-trip; C++ never // dereferences it as Rust data) so it stays `unsafe fn`. unsafe extern "C" { - safe fn JSC__VM__setControlFlowProfiler(vm: &VM, enabled: bool); + safe fn JSC__VM__enableControlFlowProfiler(vm: &VM); safe fn JSC__VM__hasExecutionTimeLimit(vm: &VM) -> bool; // safe: `VM` is an opaque `UnsafeCell`-backed ZST handle (`&` is ABI-identical // to non-null `*const`); `ctx` is an opaque round-trip pointer C++ only forwards @@ -50,8 +50,8 @@ impl VM { // Note: not `impl Drop` — takes a `global_object` param and `VM` is an opaque FFI handle. - pub fn set_control_flow_profiler(&self, enabled: bool) { - JSC__VM__setControlFlowProfiler(self, enabled) + pub fn enable_control_flow_profiler(&self) { + JSC__VM__enableControlFlowProfiler(self) } pub fn has_execution_time_limit(&self) -> bool { @@ -118,8 +118,19 @@ impl VM { JSC__VM__notifyNeedTermination(self) } - pub(crate) fn clear_has_termination_request(&self) { - crate::cpp::JSC__VM__clearHasTerminationRequest(self) + /// Has termination been requested on this VM (worker.terminate(), or + /// teardown's forbidExecution)? JS thread. + pub fn has_termination_request(&self) -> bool { + crate::cpp::JSC__VM__hasTerminationRequest(self) + } + + /// JS thread: make this VM's stop concrete here — afterwards a + /// TerminationException is pending (what the next exception check would + /// have done with the requester's trap). For code that learns of the stop + /// from the gate rather than from a thrown termination and must return + /// `Err(JsError::Terminated)`, which always means "exception pending". + pub fn ensure_termination_exception_pending(&self) { + crate::cpp::JSC__VM__ensureTerminationExceptionPending(self) } #[track_caller] diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index aac8df7e6432..0ee8a9c4075e 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -242,6 +242,15 @@ pub struct VirtualMachine { // outlives the VM. pub arena: Option>, pub has_loaded: bool, + /// The current entry load reached module evaluation: the root's graph is + /// linked and executing (set from the moduleLoaderEvaluate hook once the + /// root record — `Bun__VM__entryRootKey` — is Evaluating; a module some + /// other root evaluates meanwhile, e.g. a preload's un-awaited import(), + /// does not count). What is still pending on the entry promise after that + /// is a top-level await. + pub entry_evaluation_started: bool, + /// This VM's live pool jobs (`bun_jsc::job`); JS thread only, zero-valid. + pub(crate) jobs: core::cell::UnsafeCell, pub(crate) had_errors: bool, @@ -318,8 +327,16 @@ pub struct VirtualMachine { pub module_loader: ModuleLoader::ModuleLoader, pub(crate) gc_controller: crate::GarbageCollectionController, - // BACKREF — WebWorker owns the VM. Real type: `*const bun_runtime::webcore::WebWorker`. + /// The `WebWorker` whose thread this VM runs on (`None` on the main thread). + /// The thread holds a ref on it for its whole life. pub worker: Option<*const c_void>, + /// Workers created on this thread and not yet released by it. Parent-thread + /// only: `WebWorker::create` pushes, `release_parent_poll_ref` removes, + /// `join_child_workers` drains at exit. + pub child_workers: Vec<*mut crate::web_worker::WebWorker>, + /// The one object other threads hold to reach this VM (post completions, + /// wake, keep-alive, borrow VM-owned memory); closed by `teardown`. + handle: core::mem::ManuallyDrop, pub pending_ipc: Option, pub hot_reload_counter: u32, @@ -379,13 +396,18 @@ unsafe extern "C" { safe fn Process__dispatchOnBeforeExit(global: &JSGlobalObject, code: u8); safe fn Process__dispatchOnExit(global: &JSGlobalObject, code: u8); - safe fn Bun__closeAllSQLiteDatabasesForTermination(); + safe fn Bun__closeAllSQLiteDatabasesForTermination(global: &JSGlobalObject); safe fn Bun__closeAllNodeSqliteDatabasesForTermination(global: &JSGlobalObject); safe fn Bun__WebView__closeAllForTermination(); + safe fn Zig__GlobalObject__prepareForDestruction(global: &JSGlobalObject); + safe fn Zig__GlobalObject__forbidExecution(global: &JSGlobalObject); + safe fn Zig__GlobalObject__stopActiveDOMObjectsForTestIsolation(global: &JSGlobalObject); safe fn Zig__GlobalObject__destructOnExit(global: &JSGlobalObject); - safe fn Bun__JSCTaskScheduler__markShuttingDown(global: &JSGlobalObject); + safe fn WebWorker__teardownJSCVM(global: &JSGlobalObject); } +bun_core::define_scoped_log!(teardown_log, Worker, hidden); + pub const HOT_RELOAD_HOT: u8 = 1; pub const HOT_RELOAD_WATCH: u8 = 2; @@ -530,6 +552,33 @@ impl ExitHandler { vm.exit_handler.exit_code } + #[unsafe(no_mangle)] + pub(crate) extern "C" fn Bun__VM__noteEntryEvaluationStarted(vm: &mut VirtualMachine) { + vm.entry_evaluation_started = true; + } + + /// Only a worker's start waits on this (`wait_for_worker_entry_evaluation`); + /// any other VM answers `true` so the hook's registry probe never runs there. + #[unsafe(no_mangle)] + pub(crate) extern "C" fn Bun__VM__entryEvaluationStarted(vm: &VirtualMachine) -> bool { + vm.entry_evaluation_started || vm.worker.is_none() + } + + /// The module-registry key of the current entry load's root: the + /// `bun:main` wrapper when the entry is transpiled through it, else the + /// entry path itself (see `reload_entry_point`). Borrowed. + #[unsafe(no_mangle)] + pub(crate) extern "C" fn Bun__VM__entryRootKey( + vm: &VirtualMachine, + out: &mut bun_core::String, + ) { + *out = if !vm.transpiler.options.disable_transpilation && !vm.main_is_html_entrypoint { + bun_core::String::static_(MAIN_FILE_NAME) + } else { + bun_core::String::borrow_utf8(vm.main()) + }; + } + #[unsafe(no_mangle)] pub(crate) extern "C" fn Bun__setExitCode(vm: &mut VirtualMachine, code: u8) { vm.exit_handler.exit_code = code; @@ -542,10 +591,11 @@ impl ExitHandler { /// reference instead; the body re-enters JS so no `&mut` is held. pub(crate) fn dispatch_on_exit(vm: &VirtualMachine) { let exit_code = vm.exit_handler.exit_code; - Process__dispatchOnExit(vm.global(), exit_code); + // `process.on('exit')` handlers are user script (see `on_exit`). + if vm.script_allowed() { + Process__dispatchOnExit(vm.global(), exit_code); + } if vm.worker.is_none() { - Bun__closeAllSQLiteDatabasesForTermination(); - Bun__closeAllNodeSqliteDatabasesForTermination(vm.global()); Bun__WebView__closeAllForTermination(); } } @@ -553,6 +603,9 @@ impl ExitHandler { /// See [`dispatch_on_exit`] for the `&mut self → &VirtualMachine` /// signature change. pub(crate) fn dispatch_on_before_exit(vm: &VirtualMachine) { + if !vm.script_allowed() { + return; + } let exit_code = vm.exit_handler.exit_code; let global = vm.global(); let _ = jsc::from_js_host_call_generic(global, || { @@ -790,6 +843,61 @@ impl VirtualMachine { unsafe { &*self.event_loop } } + /// A clone of this VM's [`crate::VmHandle`] — what off-thread work captures + /// instead of a pointer to the VM or its event loop. + #[inline] + pub fn handle(&self) -> crate::VmHandle { + (*self.handle).clone() + } + + /// May native code enter user JavaScript right now? `true` in normal + /// operation and while the exit handlers run; `false` once teardown has + /// forbidden execution — the start of [`teardown`](Self::teardown), or + /// already in `on_exit` for a worker its parent terminated. Node's + /// `can_call_into_js()`. JS thread. + /// + /// This is **not** something callers of `JSValue::call` consult: once + /// script is forbidden, the native→JS boundary itself + /// (`Bun__JSValue__call`, WebCore's `JSEventListener`) turns a call into a + /// silent no-op and JSC discards microtasks — the same model as Node's + /// `InternalMakeCallback` and WebCore's `isJSExecutionForbidden`. Read it + /// only for a *resource* decision that differs during teardown (e.g. + /// "release this parked request instead of waiting for a JS consumer"), + /// never as a guard in front of a call. + #[inline] + pub fn script_allowed(&self) -> bool { + self.handle.script_allowed() + } + + /// From here no script runs on this VM: JS entry is refused at the + /// native→JS boundary (`Bun__JSValue__call`, `EventLoop::run_callback*`, + /// WebCore's `JSEventListener`) and JSC discards microtasks. Both the + /// ordinary teardown (after the stop phase) and a parent-terminated + /// worker's exit (before it) go through here. + pub fn forbid_script(&self) { + Zig__GlobalObject__forbidExecution(self.global()); + self.handle.stop(); + } + + /// Which embedded loop is current (`event_loop` points at the regular loop + /// except while a macro runs). Off-thread completions carry this so they + /// land on the loop that was current when their work started. + #[inline] + pub fn current_loop_kind(&self) -> crate::LoopKind { + if core::ptr::eq( + self.event_loop.cast_const(), + &raw const self.regular_event_loop, + ) { + crate::LoopKind::Regular + } else { + debug_assert!(core::ptr::eq( + self.event_loop.cast_const(), + &raw const self.macro_event_loop + )); + crate::LoopKind::Macro + } + } + /// Alias for [`Self::event_loop_mut`]. Kept for callers migrated on the /// `runtime-hostfn-safe` branch; both names funnel into the single audited /// `unsafe` deref above. @@ -1023,17 +1131,11 @@ impl VirtualMachine { /// Exported to C++ as `Bun__VM__scriptExecutionStatus` via virtual_machine_exports.rs. pub fn script_execution_status(&self) -> crate::ScriptExecutionStatus { - if self.is_shutting_down { - return crate::ScriptExecutionStatus::Stopped; - } - - if let Some(worker) = self.worker_ref() { - if worker.has_requested_terminate() { - return crate::ScriptExecutionStatus::Stopped; - } + if self.is_shutting_down || !self.script_allowed() { + crate::ScriptExecutionStatus::Stopped + } else { + crate::ScriptExecutionStatus::Running } - - crate::ScriptExecutionStatus::Running } /// Per-callback hot path: `drain_microtasks_with_global` calls @@ -1076,6 +1178,8 @@ impl VirtualMachine { && ((active as usize) + self.active_tasks + el.tasks.readable_length() + + el.yield_tasks.len() + + (!el.concurrent_tasks.is_empty() as usize) + (el.has_pending_refs() as usize) > 0) } @@ -1496,6 +1600,27 @@ impl VirtualMachine { } pub fn on_exit(&mut self) { + // Decide once whether the exit sequence may run script. It can be + // entered with an exception pending: `process.exit()` from inside a + // throwing/catching callback (an ordinary exception — cleared here, as + // Node's EmitProcessExit does under a TryCatch), or after + // `worker.terminate()` / a termination request (script must not run at + // all: no 'exit' handlers or close events for a forcefully terminated + // worker, matching Node and WebCore's terminate()). + { + unsafe extern "C" { + safe fn Bun__GlobalObject__clearExceptionsForExit(global: &JSGlobalObject); + } + Bun__GlobalObject__clearExceptionsForExit(self.global()); + // A worker stopped by its parent (worker.terminate()) runs no exit + // handlers. A worker exiting on its own — process.exit(), or an + // uncaught exception — does, even though Bun stops its script with + // the same trap. + if self.worker_ref().is_some_and(|w| w.stopped_by_parent()) { + self.forbid_script(); + } + } + // Write CPU profile if profiling was enabled - do this FIRST before any // shutdown begins. Grab the config and null it out to make this // idempotent. @@ -1561,106 +1686,282 @@ impl VirtualMachine { // self.event_loop().tick(); if self.should_destruct_main_thread_on_exit() { - #[cfg(windows)] - if let Some(t) = self.event_loop_mut().forever_timer.take() { - // SAFETY: `t` is the live usockets timer created in - // `EventLoop::tick_possibly_forever`; `close::()` - // (fallthrough) frees it without re-entering the loop. - unsafe { uws::Timer::close::(t.as_ptr()) }; - } - // Drain `TimeoutObject`s / `ImmediateObject`s from `All.timers` - // while `runtime_state`, the event loop, and the JSC heap are all - // still alive: drops their JS pins and in-heap `+1` refs so the GC - // sweep below (`destructOnExit` → `lastChanceToFinalize`) collects - // them instead of leaking. Must precede `close_all_socket_groups` - // and `~RunLoop::Timer` so no dangling `WTFTimer` heap node is - // observed during the walk. - if let Some(hooks) = runtime_hooks() { - // SAFETY: `self` is the live per-thread VM on the JS thread; - // `runtime_state` is still installed (it's torn down in - // `destroy()`, well after `global_exit`). - unsafe { (hooks.cancel_all_timers)(core::ptr::from_mut(self)) }; - } - // Same reason: the GC timers are heap nodes too. - self.gc_controller.deinit(); - // Detached worker threads may still be in startVM()/spin() using - // the process-global resolver BSSMap singletons. transpiler.deinit() - // below frees those singletons, so request termination of every - // live worker and wait for each to reach shutdown() first. - if let Some(hooks) = runtime_hooks() { - // Main-thread only; futex-waits on every registered worker - // until each unparks at shutdown(). - (hooks.terminate_all_workers_and_wait)(10_000); - } + // SAFETY: main-thread VM on the main thread; exit handlers have run. + unsafe { Self::teardown(core::ptr::from_mut(self), Teardown::MainThreadExit) }; + } else { + self.close_sqlite_databases_for_exit(); + } + bun_core::Global::exit(u32::from(self.exit_handler.exit_code)) + } + + /// Checkpoint + close the sqlite connections *this* VM opened, while it is + /// alive and no user script will touch them again. Never another VM's: a + /// worker still running when the main thread exits without joining it owns + /// live objects on another thread; its WAL is recovered on next open, as + /// after any abrupt exit (and as in Node). + fn close_sqlite_databases_for_exit(&self) { + Bun__closeAllSQLiteDatabasesForTermination(self.global()); + Bun__closeAllNodeSqliteDatabasesForTermination(self.global()); + } - // Mirror web_worker.rs::shutdown(): fence DeferredWorkTimer - // producers before the drain so a cross-thread scheduleWorkSoon - // that raced the shutdown either enqueued (and is caught by the - // drain below) or observes the flag under m_lock and drops. - // destructOnExit sets it again (idempotently). - Bun__JSCTaskScheduler__markShuttingDown(self.global()); - - // Every worker has now posted its close task to our concurrent - // queue (OUTSTANDING is decremented after dispatchExit). Drop - // those queued lambdas — without running them — so the captured - // `Ref` releases and the final GC sweep below brings the - // refcount to zero (`~Worker` → `WebWorker__destroy`). Must - // precede `destructOnExit`: deleting after JSC VM teardown would - // run `~JSEventListener` against freed Weak handle storage. - self.event_loop_mut().drop_concurrent_cpp_tasks(); - - // Embedded per-VM socket groups must drain while JSC is still - // alive (closeAll() fires on_close → JS). After JSC teardown, - // RareData's Drop only deinit()s the groups (asserts empty). - if self.rare_data.is_some() { - // Note: reshaped for borrowck — `close_all_socket_groups` - // walks the loop's group list via `vm.uws_loop()` and never - // touches `vm.rare_data`, so the disjoint reborrow is sound. - // SAFETY: `self` is the live per-thread VM; the shared borrow - // only reads `event_loop_handle` (no overlap with `rare_data`). - let vm_ref = unsafe { &*core::ptr::from_ref(self) }; - self.rare_data - .as_deref_mut() - .unwrap() - .close_all_socket_groups(vm_ref); + /// Tear down this thread's VM: the one sequence both a finished worker + /// thread and the exiting main thread (under `BUN_DESTRUCT_VM_ON_EXIT`) run. + /// The exit handlers ran before this (`on_exit`); nothing below enters script. + /// + /// A. stop — script forbidden (microtasks/modules cleared, termination + /// requested), WebCore stop phase (child workers asked to terminate; + /// ports/channels/WebSockets closed without events; listeners stripped), + /// everything registered as stoppable closed natively (servers, + /// listeners, sockets, watchers, subprocess/pipe/tty handles, in-flight + /// fetch/S3/Bun.build aborted or cancelled), socket groups and the DNS + /// channel closed. + /// B. release — cron, user timers and GC-controller timers cancelled; child + /// workers joined; this VM's sqlite connections closed; the JS side of + /// boxed pool jobs released; counted off-thread work waited for; main: + /// HTTP thread parked; the VM handle closed; (Windows worker) in-flight + /// uv requests completed; queued tasks and RareData's JS handles + /// released without running. + /// C. JSC VM destroyed (finalizers close what only they own; JSC's RunLoop + /// timers ride the timer heap until ~VM returns); sockets they closed + /// drained; then the timer heap's loop handles closed. + /// D. worker: keep-alive delta folded, uSockets loop freed and (Windows) + /// the uv loop closed — every handle unlinks while its owner is still + /// allocated. Main proceeds to process exit instead. + /// E. `destroy()`: RareData, runtime state, event loop. + /// + /// # Safety + /// `this` is this thread's VM and no other thread can reach it any more + /// (worker: unpublished under `vm_lock`); `is_shutting_down` is set and + /// the exit handlers have run. + pub(crate) unsafe fn teardown(this: *mut Self, kind: Teardown) { + // SAFETY: per fn contract — sole owner on the owning thread. Shared + // here; the few `&mut` steps below are statement-scoped, because the + // hooks re-derive their own access from `this`. + let vm = unsafe { &*this }; + let hooks = runtime_hooks(); + + // ---- A. no more script; stop phase ------------------------------------ + // The user's last word was the exit handlers (`on_exit`, before this). + // Everything below closes natively and dispatches nothing into JS — + // Node runs its environment cleanup under a DisallowJavascriptExecution + // scope, WebCore's ActiveDOMObject::stop() runs no script — so no + // 'close'/'error' handler runs after 'exit', and nothing can reopen what + // a sweep just closed. + vm.forbid_script(); + Zig__GlobalObject__prepareForDestruction(vm.global()); + // SAFETY: fn contract. + let sweep = || unsafe { + let _ = Self::stop_phase_sweep(this, kind); + let second = Self::stop_phase_sweep(this, kind); + debug_assert!( + second == SweepResult::Idle, + "a native close path registered a stoppable resource during teardown" + ); + }; + sweep(); + // A worker closes its uv loop below (D), so requests still in flight + // complete here, against this live VM: their handles were just closed, + // so what remains finishes on its own (threadpool work), and a + // completion may start more — open a handle, schedule pool work (still + // accepted, and awaited in B) — hence sweep again after each drain. + // The exiting main thread neither closes its loop nor may nest uv_run + // here: process.exit() can be running inside a libuv completion callback. + #[cfg(windows)] + if matches!(kind, Teardown::Worker) { + while bun_sys::windows::libuv::Loop::drain_requests() { + sweep(); } - // Destroy the per-VM c-ares channel while JSC / `RareData.file_polls` - // / `runtime_state` are all still live — `ares_destroy()` re-enters - // them from its EDESTRUCTION and socket-state callbacks. Mirrors - // `WebWorker::shutdown`. - if let Some(hooks) = runtime_hooks() { - (hooks.close_dns_for_terminate)(); + } + teardown_log!("teardown: stopped"); + + // ---- B. release ------------------------------------------------------ + #[cfg(windows)] + if let Some(t) = vm.event_loop_mut().forever_timer.take() { + // SAFETY: live usockets timer from `hold_forever_poll`; closed like + // any us_timer (freed by its close callback when the loop turns). + unsafe { uws::Timer::close::(t.as_ptr()) }; + } + if let Some(hooks) = hooks { + // SAFETY: fn contract (statement-scoped exclusive access). + (hooks.stop_cron_for_vm_teardown)(unsafe { &mut *this }); + // Drop every TimeoutObject/ImmediateObject's heap node, JS pin and + // +1 while runtime state and the JSC heap are alive. The heap itself + // (and the loop handle it embeds) stays up: JSC's own RunLoop timers + // — GC activity callbacks, sweeper, deferred work — are WTFTimers on + // this heap and keep being scheduled until ~VM returns. + // SAFETY: fn contract. + unsafe { (hooks.cancel_all_timers)(this) }; + // And unlink every other kind of EventLoopTimer (socket timeouts, + // reconnect/lifetime timers, schedulers): their owners stay valid + // and find them CANCELLED, but nothing fires again even where the + // loop still turns (JSC's timers). + // SAFETY: fn contract. + unsafe { (hooks.disarm_all_timers_for_vm_teardown)(this) }; + } + // SAFETY: fn contract (statement-scoped exclusive access). + unsafe { + (*this).gc_controller.deinit(); + crate::web_worker::join_child_workers(&mut *this); + } + // Children have closed their own; now this VM's sqlite connections + // checkpoint and close, before finalizers could. + vm.close_sqlite_databases_for_exit(); + // Work still out on other threads (pool jobs, fetches): its JS side + // — promises, callbacks, pins, protected buffers, keep-alives — is + // released here, on this thread with the heap alive. After `close()` + // below the other thread cannot hand it back; it frees only its own part. + vm.jobs().release_all_js(&vm.global().js_thread()); + // Pool work stored inside JS-owned objects (transpile slots, zlib + // streams) must be back before the handle closes: it completes into the + // still-open queue and is released below, on this thread. + teardown_log!( + "teardown: waiting for {} unit(s) of off-thread work", + vm.handle.embedded_work_outstanding() + ); + vm.handle.wait_for_embedded_work(); + // The exiting main thread now parks the process-wide HTTP thread — + // after the children it also served are joined and this VM's own + // requests are back — so it cannot touch what process exit frees. If + // it does not acknowledge (≤1 s), leave the rest to process exit. + if matches!(kind, Teardown::MainThreadExit) && !bun_http::shutdown_for_exit() { + teardown_log!("teardown: HTTP thread unresponsive; skipping to process exit"); + return; + } + // From here no other thread reaches this VM: posts are refused (the + // poster releases its task itself), wake/keep-alive are no-ops, and any + // job still using VM-owned memory has finished (close waits for it). + vm.handle.close(); + // Tasks posted by other threads (HTTP, children before they were + // joined) or by the request completions in A: release, do not run — + // their JSC handles must drop against a live heap. + // SAFETY: fn contract (statement-scoped exclusive access). + unsafe { + (*this).release_queued_work(); + if let Some(rare) = (*this).rare_data.as_deref_mut() { + rare.release_js_handles(); + } + } + teardown_log!("teardown: script forbidden, resources cancelled, children joined"); + + // ---- C. JSC VM ------------------------------------------------------- + match kind { + Teardown::Worker => WebWorker__teardownJSCVM(vm.global()), + Teardown::MainThreadExit => Zig__GlobalObject__destructOnExit(vm.global()), + } + // Finalizers just closed the sockets only they owned; `us_socket_close` + // queues onto `loop->data.closed_head`, normally freed on the next tick. + vm.uws_loop_mut().drain_closed_sockets(); + // Nothing schedules a WTFTimer any more: the timer heap's loop handles + // can go (Windows uv_timer/uv_idle), before the loop close unlinks them. + if let Some(hooks) = hooks { + // SAFETY: fn contract; runtime state still installed. + unsafe { (hooks.close_timer_loop_handles_after_vm_destroyed)(this) }; + } + teardown_log!("teardown: JSC VM destroyed"); + + // ---- D. loops (worker; main exits the process instead) ---------------- + if matches!(kind, Teardown::Worker) { + // Whatever B/C unref'd through the concurrent counter, folded now: + // on Windows it shares `active_handles` with libuv and a residue + // would keep the loop close below spinning. + vm.event_loop_mut().apply_concurrent_ref_delta(); + // SAFETY: fn contract (statement-scoped exclusive access). + if let Some(rare) = unsafe { (*this).rare_data.as_deref_mut() } { + rare.detach_socket_groups_from_loop(); } + // SAFETY: this thread's loop; nothing ticks it any more. + unsafe { (*vm.uws_loop()).internal_loop_data.jsc_vm = core::ptr::null_mut() }; + bun_uws::free_thread_loop(); + teardown_log!("teardown: uSockets loop freed"); + #[cfg(windows)] + bun_sys::windows::libuv::Loop::close_thread_loop(); + teardown_log!("teardown: loops closed"); + } - // The HTTP daemon thread holds a `Box` per - // in-flight request; with the JS thread exiting those never reach - // a terminal state. Ask it to reclaim them now (waits up to 1s). - bun_http::shutdown_for_exit(); + // ---- E. free owners -------------------------------------------------- + // SAFETY: fn contract; last use of `this`. + unsafe { (*this).destroy() }; + } +} - // Release tasks the HTTP daemon posted before observing `is_shutting_down` (else the - // tasklet ⇄ `Box` cycle leaks); must precede `destructOnExit`. Wait for - // work-pool fs completions first — they post without a shutdown check, so a post - // landing after the drain leaks. - self.event_loop_mut().wait_for_concurrent_posters(); - self.event_loop_mut().release_queued_tasks_for_shutdown(); +impl VirtualMachine { + /// One stop-phase sweep: registered handles (servers, listeners, watchers, + /// duplex/named-pipe sockets, resolvers), a worker's uv stream/process + /// handles, every socket group, the VM-global dns channel. Reports whether + /// it found anything. + /// + /// # Safety + /// As [`teardown`](Self::teardown): sole owner on the owning thread, heap alive. + unsafe fn stop_phase_sweep(this: *mut Self, kind: Teardown) -> SweepResult { + let hooks = runtime_hooks(); + let mut result = SweepResult::Idle; + if let Some(hooks) = hooks { + // SAFETY: fn contract. + result = result.and(unsafe { (hooks.stop_active_handles_for_vm_teardown)(this) }); + } + // A worker's uv loop is closed in D, so every pipe / tty / child-process + // handle open on it closes now — through whoever drives it (reader, + // writer, IPC channel, named pipe, Process), or directly if nothing + // adopted it — so pending writes complete (ECANCELED) against a live VM + // and no request on them can hold up the loop drain in B. The exiting + // main thread keeps its loop (the OS reclaims the handles); sweeping + // them there only re-enters stream owners under still-running script. + #[cfg(windows)] + if matches!(kind, Teardown::Worker) { + bun_sys::windows::libuv::open_handles::stop_all_for_vm_teardown(); + } + let _ = kind; + // `close_all_socket_groups` walks the loop's group list through the VM + // and never touches `rare_data`, so the two accesses are disjoint. + // SAFETY: fn contract. + if let Some(rare) = unsafe { (*this).rare_data.as_deref_mut() } { + // SAFETY: as above. + result = result.and(rare.close_all_socket_groups(unsafe { &*this })); + } + if let Some(hooks) = hooks { + result = result.and((hooks.stop_dns_for_vm_teardown)()); + } + result + } - if let Some(rare) = self.rare_data.as_deref_mut() { - rare.release_js_handles(); - } + /// Release — never run — everything queued on both event loops (tasks, + /// concurrent tasks, pending immediates) while the JSC heap and this + /// thread's loop are alive: their JS handles and keep-alives drop now. The + /// macro loop is only ever ticked explicitly, so whatever a macro queued on + /// it is still there. Teardown phase B; also the one thing an owner that + /// calls `destroy()` without a teardown (bake's build VM) must do first. + pub fn release_queued_work(&mut self) { + self.regular_event_loop.release_queued_tasks(); + self.macro_event_loop.release_queued_tasks(); + self.transpiler_store.release_queued_jobs_for_teardown(); + } +} - Zig__GlobalObject__destructOnExit(self.global()); +/// Which exit funnel is running [`VirtualMachine::teardown`]. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Teardown { + Worker, + MainThreadExit, +} - // lastChanceToFinalize() above runs Listener/Server finalize → - // their own embedded group.closeAll() → sockets land in - // loop.closed_head. Drain again now or LSAN reports every accepted - // socket that was still open at process.exit(). - // SAFETY: `uws::Loop::get()` returns the process-global usockets - // loop, which is live for the process lifetime. - unsafe { (*uws::Loop::get()).drain_closed_sockets() }; +/// What one stop-phase sweep found (see `VirtualMachine::stop_phase_sweep`). +#[derive(Clone, Copy, PartialEq, Eq)] +#[must_use] +pub enum SweepResult { + /// Nothing was registered / open / pending; the sweep did no work. + Idle, + /// The sweep stopped or closed at least one thing (whose close handlers may + /// have opened something else). + Stopped, +} - self.destroy(); +impl SweepResult { + pub fn and(self, other: SweepResult) -> SweepResult { + if self == SweepResult::Stopped || other == SweepResult::Stopped { + SweepResult::Stopped + } else { + SweepResult::Idle } - bun_core::Global::exit(u32::from(self.exit_handler.exit_code)) } } @@ -1816,17 +2117,9 @@ pub struct RuntimeHooks { unsafe fn(exec_argv: &[bun_core::WTFStringImpl]) -> Option, /// `CronJob.clearAllForVM(vm, .teardown)`. `CronJob` lives in /// `bun_runtime::api::cron`. - pub cron_clear_all_teardown: fn(vm: &mut VirtualMachine), - /// `WebWorker.terminateAllAndWait(timeout_ms)`. - /// `WebWorker` lives in this crate but the - /// `web_worker` module is above `virtual_machine` in the dep graph - /// (forward use) AND the body re-enters `bun_runtime` for the worker - /// thread's `event_loop().auto_tick()`, so [`global_exit`] reaches it - /// through this slot. Prevents detached worker threads from racing the - /// freed resolver BSSMap singletons during `transpiler.deinit()`. - pub terminate_all_workers_and_wait: fn(timeout_ms: u64), + pub stop_cron_for_vm_teardown: fn(vm: &mut VirtualMachine), /// `CronJob.clearAllForVM(vm, .reload)`. - /// Same impl as `cron_clear_all_teardown` but + /// Same impl as `stop_cron_for_vm_teardown` but /// the `.reload` mode preserves the next-fire schedule across the new /// global so timers re-register instead of being torn down. pub cron_clear_all_reload: fn(vm: &mut VirtualMachine), @@ -1868,7 +2161,23 @@ pub struct RuntimeHooks { /// runs those callbacks against freed state. No-op when the resolver was /// never lazily created. Called from `WebWorker::shutdown` / `global_exit` /// right after `close_all_socket_groups`. - pub close_dns_for_terminate: fn(), + pub stop_dns_for_vm_teardown: fn() -> SweepResult, + /// Stop every registered native handle behind a JS object (servers, + /// listeners, fs watchers) — the stop phase for Rust-side JS classes, run + /// with the VM alive right after the WebCore stop phase. + /// + /// # Safety + /// `vm` is the live per-thread VM on the JS thread; the JSC heap is alive. + pub stop_active_handles_for_vm_teardown: unsafe fn(vm: *mut VirtualMachine) -> SweepResult, + /// Teardown only (never on a live VM): unlink every remaining EventLoopTimer. + pub disarm_all_timers_for_vm_teardown: unsafe fn(vm: *mut VirtualMachine), + /// Teardown-only, after ~VM (JSC's RunLoop timers use the heap until then): + /// close the loop handles the timer heap embeds (Windows uv_timer/uv_idle) + /// so the loop close unlinks them before the runtime state is freed. + /// + /// # Safety + /// JS thread; `runtime_state` installed. + pub close_timer_loop_handles_after_vm_destroyed: unsafe fn(vm: *mut VirtualMachine), } /// Canonical `EventLoopCtx` vtable for a `*mut VirtualMachine` owner — the JS @@ -1901,12 +2210,6 @@ bun_io::link_impl_EventLoopCtx! { .pending_unref_counter .fetch_add(1, core::sync::atomic::Ordering::Relaxed); }, - // CROSS-THREAD: reached via `KeepAlive::{,un}ref_concurrently`. Do NOT - // use `vm_from_owner()` / `event_loop_mut()` — both mint `&mut`, UB - // against the JS thread's borrow. `event_loop()` takes `&self` (Sync) - // and `{,un}ref_concurrently` take `&self` (atomic fetch_add + wakeup). - ref_concurrently() => (*(*this).event_loop()).ref_concurrently(), - unref_concurrently() => (*(*this).event_loop()).unref_concurrently(), after_event_loop_callback() => vm_from_owner(this.cast()).after_event_loop_callback, set_after_event_loop_callback(cb, ctx) => { let vm = vm_from_owner(this.cast()); @@ -2144,6 +2447,9 @@ impl VirtualMachine { // their validity invariants even when len/cap are 0. Write the // canonical empty value via `ptr::write` (no Drop of zeroed bytes). addr_of_mut!((*vm).preload).write(Vec::new()); + addr_of_mut!((*vm).child_workers).write(Vec::new()); + addr_of_mut!((*vm).handle) + .write(core::mem::ManuallyDrop::new(crate::VmHandle::new(vm))); addr_of_mut!((*vm).argv).write(Vec::new()); addr_of_mut!((*vm).resolved_path_dups).write(Vec::new()); addr_of_mut!((*vm).macros).write(Default::default()); @@ -2287,9 +2593,8 @@ impl VirtualMachine { /// `promise` settles. Thin forwarder; body lives in /// [`crate::event_loop::EventLoop::wait_for_promise`]. #[inline] - pub fn wait_for_promise(&mut self, promise: jsc::AnyPromise) { - // accessed here (no overlapping `&mut EventLoop`). - self.event_loop_mut().wait_for_promise(promise); + pub fn wait_for_promise(&mut self, promise: jsc::AnyPromise) -> Result<(), jsc::JsTerminated> { + self.event_loop_mut().wait_for_promise(promise) } /// `eventLoop().autoTick()` — dispatched through the runtime hook @@ -2422,6 +2727,9 @@ impl VirtualMachine { } } + // Preloads (evaluated above, synchronously) are not the entry: only + // module evaluations from here on mark the entry graph as executing. + self.entry_evaluation_started = false; // Note: reshaped for borrowck — capture raw ptr before &self call. let global = self.global; let global_ref = self.global(); @@ -2446,6 +2754,7 @@ impl VirtualMachine { JSValue::from_cell(promise).ensure_still_alive(); Ok(promise) } else { + self.entry_evaluation_started = false; let global = self.global; let main_str = bun_core::String::from_bytes(self.main()); let promise = @@ -2494,7 +2803,7 @@ impl VirtualMachine { return Ok(promise); } self.event_loop_mut().perform_gc(); - self.wait_for_promise(jsc::AnyPromise::Internal(promise)); + let _ = self.wait_for_promise(jsc::AnyPromise::Internal(promise)); } Ok(self.pending_internal_promise.unwrap_or(promise)) @@ -3205,20 +3514,25 @@ impl VirtualMachine { // non-negative values that fit in i31 (i.e. `0..=i32::MAX`). // Parsing as `u32` then `as i32` would silently wrap values in // `2^31..2^32` to a negative fd instead of taking the warn branch. - match bun_core::fmt::parse_int::(&fd_s, 10) - .ok() - .filter(|&n| n >= 0) - { - Some(fd) => { - self.pending_ipc = Some(PendingIpc { - fd: bun_sys::Fd::from_uv(fd), - advanced, - }) + // The channel belongs to the process (its main thread). A worker + // sees the same inherited variables but must not open a second + // endpoint over the same fd (Node: no process.send() in workers). + if self.is_main_thread() { + match bun_core::fmt::parse_int::(&fd_s, 10) + .ok() + .filter(|&n| n >= 0) + { + Some(fd) => { + self.pending_ipc = Some(PendingIpc { + fd: bun_sys::Fd::from_uv(fd), + advanced, + }) + } + None => bun_core::warn!( + "Failed to parse IPC channel number '{}'", + bstr::BStr::new(&fd_s[..]) + ), } - None => bun_core::warn!( - "Failed to parse IPC channel number '{}'", - bstr::BStr::new(&fd_s[..]) - ), } } @@ -3570,15 +3884,6 @@ impl VirtualMachine { self.event_loop_mut().enqueue_immediate_task(task); } - /// Enqueues a task from another thread onto this VM's event loop. - #[inline] - pub fn enqueue_task_concurrent( - &mut self, - task: core::ptr::NonNull, - ) { - self.event_loop_mut().enqueue_task_concurrent(task); - } - /// Ticks the event loop until no tasks keep it alive. pub fn wait_for_tasks(&mut self) { while self.is_event_loop_alive() { @@ -3636,11 +3941,9 @@ impl VirtualMachine { smol: opts.smol, eval_mode: opts.eval, is_main_thread: false, - // The global is created - // with `worker.cpp_worker`, `worker.execution_context_id`, - // and `worker.mini` so the C++ ZigGlobalObject is born with its - // WorkerGlobalScope + debugger context id wired. - worker_ptr: worker.cpp_worker(), + // The global is created with the worker's messaging proxy, context id + // and `mini` so the C++ ZigGlobalObject is born with its options wired. + worker_ptr: worker.messaging_proxy(), context_id: Some(worker.execution_context_id() as i32), mini_mode: worker.mini(), ..Default::default() @@ -3652,6 +3955,12 @@ impl VirtualMachine { // SAFETY: `vm` is the unique live VM on this thread. let vm_ref = unsafe { &mut *vm }; vm_ref.worker = Some(std::ptr::from_ref::(worker).cast()); + #[cfg(debug_assertions)] + if bun_core::env_var::feature_flag::BUN_DEBUG_TEST_WORKER_REFUSAL_GATE::get() + .unwrap_or(false) + { + vm_ref.handle.park_posts_until_closed(); + } // `parent_vm()` is a `BackRef`; the parent outlives this worker while // `parent_poll_ref` is held (see web_worker.rs file header). let parent = worker.parent_vm(); @@ -4381,6 +4690,10 @@ impl VirtualMachine { pub fn destroy(&mut self) { self.regular_event_loop.deinit(); self.macro_event_loop.deinit(); + // The VM's own clone of its handle; the shared inner is freed when the + // last outside holder (C++ client data, a late poster) lets go. + // SAFETY: `destroy` runs once; the field is not used afterwards. + unsafe { core::mem::ManuallyDrop::drop(&mut self.handle) }; // `ProcessAutoKiller`'s `Drop` // is the deinit body; take()+drop runs it without dropping `self`. @@ -4398,7 +4711,7 @@ impl VirtualMachine { // `debug_assert!(cron_jobs.is_empty())` fires. if self.rare_data.is_some() { if let Some(hooks) = runtime_hooks() { - (hooks.cron_clear_all_teardown)(self); + (hooks.stop_cron_for_vm_teardown)(self); } } if let Some(rare) = self.rare_data.take() { @@ -4530,7 +4843,10 @@ impl VirtualMachine { Ok(promise) } - /// Loads the worker entry point and waits for it, honoring termination requests. + /// Load a worker's entry: fetch and link its module graph and begin + /// evaluating it. Returns once evaluation has begun (or the load failed) — + /// the promise may still be pending on a top-level await, which then + /// continues in the worker's normal event loop, as in Node. pub(crate) fn load_entry_point_for_web_worker( &mut self, entry_path: &[u8], @@ -4538,13 +4854,13 @@ impl VirtualMachine { let promise = self.reload_entry_point(entry_path)?; self.event_loop_mut().perform_gc(); self.event_loop_mut() - .wait_for_promise_with_termination(jsc::AnyPromise::Internal(promise)); + .wait_for_worker_entry_evaluation(jsc::AnyPromise::Internal(promise)); if let Some(worker) = self.worker_ref() { if worker.has_requested_terminate() { return Err(crate::CrateError::WorkerTerminated); } } - Ok(self.pending_internal_promise.unwrap()) + Ok(promise) } /// Loads a test-file entry point and waits for the load promise to settle. @@ -4580,7 +4896,7 @@ impl VirtualMachine { return Ok(promise); } self.event_loop_mut().perform_gc(); - self.wait_for_promise(jsc::AnyPromise::Internal(promise)); + let _ = self.wait_for_promise(jsc::AnyPromise::Internal(promise)); } // Pre-arm the waker so this settled-promise tick cannot park (#36450). @@ -4649,7 +4965,7 @@ impl VirtualMachine { /// Replaces the global object between test files so each file runs in a fresh realm. /// - /// Callers must run `bun_runtime::jsc_hooks::close_isolation_handles(vm)` + /// Callers must run `bun_runtime::jsc_hooks::stop_active_handles_for_test_isolation(vm)` /// first so leaked watchers/servers are stopped (dropping their JS-side /// Strongs, which otherwise pin the outgoing global) before the blind /// socket-group close below. That helper lives in the higher-tier crate @@ -4657,6 +4973,11 @@ impl VirtualMachine { pub fn swap_global_for_test_isolation(&mut self) { debug_assert!(self.test_isolation_enabled); + // The finished file's workers, ports, channels and sockets are stopped + // first (no events dispatched), before its socket groups and timers are + // swept and before the new global exists. + Zig__GlobalObject__stopActiveDOMObjectsForTestIsolation(self.global()); + if let Some(cwd) = self.test_isolation_state.saved_cwd.take() { let mut buf = bun_paths::PathBuffer::uninit(); let z = bun_paths::resolve_path::z(&cwd, &mut buf); @@ -4724,7 +5045,7 @@ impl VirtualMachine { // // The hook also runs `StatWatcherScheduler::shutdown_for_exit` first: // it drains the (already-closed — the caller ran - // `close_isolation_handles` before this swap) watcher queue and retires + // `stop_active_handles_for_test_isolation` before this swap) watcher queue and retires // the per-VM scheduler singleton, which the next file's first // `fs.watchFile` lazily recreates. That per-file reset is intentional // — the scheduler's queue and in-flight work-pool task belong to the @@ -4784,7 +5105,7 @@ impl VirtualMachine { let promise = jsc::JSModuleLoader::load_and_evaluate_module_ptr(self.global, Some(&path_str))? .as_ptr(); - self.wait_for_promise(jsc::AnyPromise::Internal(promise)); + let _ = self.wait_for_promise(jsc::AnyPromise::Internal(promise)); Some(promise) } diff --git a/src/jsc/VmHandle.rs b/src/jsc/VmHandle.rs new file mode 100644 index 000000000000..1a536254a518 --- /dev/null +++ b/src/jsc/VmHandle.rs @@ -0,0 +1,927 @@ +//! [`VmHandle`] — the only way another thread reaches a [`VirtualMachine`]. +//! +//! Off-thread code (thread-pool jobs, the HTTP thread, watcher/waiter threads, +//! addon threads) legitimately needs three things from a VM: post a completion +//! and wake its loop, ref/unref its keep-alive, and — while running — sometimes +//! use memory the VM owns. It never needs the `VirtualMachine`, its global or +//! its heap directly; whatever touches those runs later, on the JS thread, from +//! the posted task. A `VmHandle` provides exactly those three, safely and for +//! as long as anyone holds it (it outlives the VM), and the VM's teardown +//! *closes* it: after `close()` returns no thread can reach the VM's queues, +//! waker or memory through any handle, and posts are refused (the poster gets +//! its task back and releases it on its own thread — deliver-or-discard, as +//! WebKit's WorkerRunLoop does). The same object carries the script-forbidden +//! bit that native→JS entry points consult (Node's `can_call_into_js`). +//! +//! Gate: posters/borrowers hold `active` for the duration of their access and +//! then check `state`; `close()` publishes `Closed` and waits for `active == 0` +//! (SeqCst on both sides — the Dekker pair). So an access either finished +//! before `close()` returned or observed `Closed` and touched nothing. + +use core::ptr::NonNull; +use core::sync::atomic::{AtomicU8, AtomicU32, Ordering}; +use std::sync::Arc; + +use bun_threading::{Condvar, Mutex}; + +use crate::event_loop::EventLoop; +use crate::virtual_machine::VirtualMachine; +use bun_event_loop::ConcurrentTask::ConcurrentTask as ConcurrentTaskItem; + +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +enum State { + /// Normal operation. + Open = 0, + /// The VM is going away — a parent's `terminate()` (from its thread) or + /// this thread's own exit/teardown: native code enters no more script and + /// starts no new off-thread work; posts are still accepted so completions + /// of already-running work are delivered (and released by the teardown). + Stopping = 1, + /// `close()` ran: nothing off-thread reaches the VM any more. + Closed = 2, +} + +/// Which of the VM's two embedded loops a task belongs to, fixed when the task +/// is created on the JS thread (a task started while a macro runs completes +/// into the macro loop). `Bun.spawnSync`'s isolated loop is not one of these: +/// its producers post through that loop's own [`JsPoster`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LoopKind { + Regular, + Macro, +} + +/// The part of [`Shared`] every native→JS entry on the JS thread reads +/// (`state`, and `vm` on each post) but that changes twice per VM lifetime. +/// Kept on its own cache line: the counters below are RMW'd by pool / HTTP +/// threads on every completion, and sharing a line with them made each of the +/// JS thread's reads a miss whenever another thread had just posted. +#[cfg_attr( + any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "powerpc64" + ), + repr(align(64)) +)] +#[cfg_attr(target_arch = "s390x", repr(align(128)))] +struct ReadMostly { + state: AtomicU8, + /// Dereferenced only while an `Access` guard is held and `state != Closed`, + /// or on the JS thread. Nulled by `close()`. + vm: core::cell::UnsafeCell<*mut VirtualMachine>, +} + +#[cfg_attr( + any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "powerpc64" + ), + repr(align(64)) +)] +#[cfg_attr(target_arch = "s390x", repr(align(128)))] +pub struct Shared { + hot: ReadMostly, + /// Threads currently inside `post`/`wake`/`ref`/`unref` or holding a + /// [`Borrow`]. `close()` waits for zero after publishing `Closed`. + active: AtomicU32, + /// For `close()` to sleep on while `active` drains (borrows may be long), + /// and `wait_for_embedded_work()` while `embedded` drains. + drained: (Mutex, Condvar), + /// Pool work scheduled with storage inside a JS-owned object (see + /// [`VmHandle::embedded_work_scheduled`]); teardown waits for zero. + embedded: AtomicU32, + #[cfg(debug_assertions)] + js_thread: std::thread::ThreadId, + /// Test suite only — see [`refusal_gate`]. + #[cfg(debug_assertions)] + park_posts: core::sync::atomic::AtomicBool, +} + +// SAFETY: `vm` is only dereferenced under the gate described in the module doc; +// everything else is atomics / std sync primitives. +unsafe impl Send for Shared {} +// SAFETY: as above. +unsafe impl Sync for Shared {} + +/// See the module documentation. `repr(transparent)` over the `Arc` so a +/// `*const VmHandle` can cross FFI (C++ / napi hold boxed clones). +#[derive(Clone)] +#[repr(transparent)] +pub struct VmHandle(Arc); + +pub use bun_event_loop::Posted; + +/// RAII: one unit of `active`. While held, `close()` cannot complete. +struct Access<'a>(&'a Shared); +impl Drop for Access<'_> { + fn drop(&mut self) { + if self.0.active.fetch_sub(1, Ordering::SeqCst) == 1 + && self.0.hot.state.load(Ordering::SeqCst) == State::Closed as u8 + { + self.0.drained.0.lock(); + self.0.drained.1.notify_all(); + self.0.drained.0.unlock(); + } + } +} + +/// An off-thread job is using memory the VM owns (request buffers, a JS +/// buffer's backing store) for as long as this is held; the VM's teardown +/// waits for it before freeing anything. Obtain with [`VmHandle::borrow`]. +pub struct Borrow { + _access: Access<'static>, + /// Keeps the `Shared` that `_access` borrows alive. + _handle: VmHandle, +} + +impl VmHandle { + /// JS thread, at VM creation. + pub(crate) fn new(vm: *mut VirtualMachine) -> Self { + VmHandle(Arc::new(Shared { + hot: ReadMostly { + state: AtomicU8::new(State::Open as u8), + vm: core::cell::UnsafeCell::new(vm), + }, + active: AtomicU32::new(0), + drained: (Mutex::new(), Condvar::new()), + embedded: AtomicU32::new(0), + #[cfg(debug_assertions)] + js_thread: std::thread::current().id(), + #[cfg(debug_assertions)] + park_posts: core::sync::atomic::AtomicBool::new(false), + })) + } + + #[inline] + fn enter(&self) -> Option> { + self.0.active.fetch_add(1, Ordering::SeqCst); + let a = Access(&self.0); + if self.0.hot.state.load(Ordering::SeqCst) == State::Closed as u8 { + drop(a); + return None; + } + Some(a) + } + + /// # Safety + /// Caller holds an `Access` obtained from `enter()` (so `state != Closed` + /// was observed after `active` was raised, and `close()` cannot have + /// returned), or is the JS thread before `close()`. + #[inline] + unsafe fn vm(&self) -> *mut VirtualMachine { + // SAFETY: per fn contract. + unsafe { *self.0.hot.vm.get() } + } + + #[inline] + fn loop_of<'a>(vm: *mut VirtualMachine, kind: LoopKind) -> &'a EventLoop { + // SAFETY: caller is inside the gate; the VM and both embedded loops are alive. + unsafe { + match kind { + LoopKind::Regular => &(*vm).regular_event_loop, + LoopKind::Macro => &(*vm).macro_event_loop, + } + } + } + + // ── off-thread API ──────────────────────────────────────────────────── + + /// Queue `task` on the VM's `kind` loop and wake it, or hand it back. + pub fn post(&self, kind: LoopKind, task: NonNull) -> Posted { + refusal_gate::before_post(self); + let Some(_a) = self.enter() else { + // SAFETY: handed to us by the caller and not yet queued anywhere. + let tag = unsafe { task.as_ref() }.task.tag; + refusal_gate::refused(self, format_args!("post: {}", tag.name())); + return Posted::Refused(task); + }; + // SAFETY: inside the gate. + let el = Self::loop_of(unsafe { self.vm() }, kind); + el.concurrent_tasks.push(task); + el.wakeup(); + Posted::Queued + } + + /// Queue a C++ `EventLoopTask` from another thread (WebCore's + /// `postTaskConcurrently`), or delete it unrun if the VM is gone — the + /// same release teardown applies to queued C++ tasks. + /// + /// # Safety + /// `task` is a live heap `WebCore::EventLoopTask` the caller hands over. + pub unsafe fn post_cpp_task(&self, task: *mut crate::cpp_task::CppTask) { + unsafe extern "C" { + fn Bun__deleteEventLoopTask(task: *mut crate::cpp_task::CppTask); + } + let ct = ConcurrentTaskItem::create(bun_event_loop::Task::init(task)); + if let Posted::Refused(ct) = self.post(LoopKind::Regular, ct) { + // SAFETY: refused ⇒ we own both boxes. + unsafe { + drop(bun_core::heap::take(ct.as_ptr())); + Bun__deleteEventLoopTask(task); + } + } + } + + /// Keep the VM's loop alive from another thread (no-op once closed; the + /// teardown ignores keep-alives anyway). + pub fn ref_keep_alive(&self, kind: LoopKind) { + if let Some(_a) = self.enter() { + // SAFETY: inside the gate. + let el = Self::loop_of(unsafe { self.vm() }, kind); + let _ = el.concurrent_ref.fetch_add(1, Ordering::SeqCst); + el.wakeup(); + } + } + + pub fn unref_keep_alive(&self, kind: LoopKind) { + if let Some(_a) = self.enter() { + // SAFETY: inside the gate. + let el = Self::loop_of(unsafe { self.vm() }, kind); + let _ = el.concurrent_ref.fetch_sub(1, Ordering::SeqCst); + el.wakeup(); + } + } + + /// This job is about to use VM-owned memory off-thread; `None` if the VM + /// is closed (touch nothing). Hold the result until done. Jobs that could + /// block indefinitely on an external party must own their memory instead. + pub fn borrow(&self) -> Option { + let a = self.enter()?; + // SAFETY: lifetime extension is sound because `Borrow` also holds a + // clone of the Arc that `a` borrows from. + let a: Access<'static> = unsafe { core::mem::transmute(a) }; + Some(Borrow { + _access: a, + _handle: self.clone(), + }) + } + + /// As [`borrow`](Self::borrow), but only while the VM is still running + /// (not yet stopping): what a pool body checks before doing work whose + /// only consumer is script. + pub fn borrow_if_running(&self) -> Option { + let b = self.borrow()?; + (self.0.hot.state.load(Ordering::SeqCst) == State::Open as u8).then_some(b) + } + + // ── embedded work ───────────────────────────────────────────────────── + // + // Pool work whose storage is a field of a JS-owned object (a transpile + // slot inside the VM, a zlib stream's native part) cannot be boxed into a + // `Job` and cannot outlive the VM. It is counted instead: teardown waits + // for the count before the handle closes, so such work always posts its + // completion into a live queue and is released on the JS thread — the + // pool side never sees a dead VM. Bodies check `borrow_if_running` so a + // stopping VM only waits for the pool to *reach* the work, not to do it. + + /// JS thread, before handing embedded work to the pool. Script starts + /// such work only while the VM is open; a native continuation that runs + /// during teardown (a release arm retrying a request) checks + /// [`accepting_work`](Self::accepting_work) first and fails instead. + pub fn embedded_work_scheduled(&self) { + debug_assert!( + self.0.hot.state.load(Ordering::SeqCst) != State::Closed as u8, + "embedded work started on a closed VM handle" + ); + self.0.embedded.fetch_add(1, Ordering::SeqCst); + } + + /// Whether new off-thread work may still be started for this VM (it has + /// not begun stopping). JS thread. + pub fn accepting_work(&self) -> bool { + self.0.hot.state.load(Ordering::SeqCst) == State::Open as u8 + } + + /// Pool thread, after its last touch of the embedded storage (i.e. after + /// posting the completion). + pub fn embedded_work_finished(&self) { + if self.0.embedded.fetch_sub(1, Ordering::SeqCst) == 1 + && self.0.hot.state.load(Ordering::SeqCst) != State::Open as u8 + { + self.0.drained.0.lock(); + self.0.drained.1.notify_all(); + self.0.drained.0.unlock(); + } + } + + pub(crate) fn embedded_work_outstanding(&self) -> u32 { + self.0.embedded.load(Ordering::SeqCst) + } + + /// Teardown (JS thread, stopping, before `close()`): wait until the pool + /// holds no embedded work of this VM. + pub(crate) fn wait_for_embedded_work(&self) { + self.assert_js_thread(); + debug_assert!(self.0.hot.state.load(Ordering::SeqCst) != State::Open as u8); + if self.0.embedded.load(Ordering::SeqCst) != 0 { + self.0.drained.0.lock(); + while self.0.embedded.load(Ordering::SeqCst) != 0 { + self.0.drained.1.wait(&self.0.drained.0); + } + self.0.drained.0.unlock(); + } + } + + // ── JS-thread API ───────────────────────────────────────────────────── + + #[cfg(debug_assertions)] + pub(crate) fn assert_js_thread(&self) { + debug_assert_eq!(std::thread::current().id(), self.0.js_thread); + } + #[cfg(not(debug_assertions))] + #[inline(always)] + pub(crate) fn assert_js_thread(&self) {} + + /// The VM is going away: `Open → Stopping` (idempotent; never reopens or + /// un-closes). Any thread — a parent's `terminate()` calls it at request + /// time, as Node's `Environment::ExitEnv` sets `is_stopping` from the + /// requesting thread; this thread's own exit path calls it via + /// `VirtualMachine::forbid_script`. + pub fn stop(&self) { + let _ = self.0.hot.state.compare_exchange( + State::Open as u8, + State::Stopping as u8, + Ordering::SeqCst, + Ordering::SeqCst, + ); + } + + /// May native code call into user JS / settle its promises right now? + /// (Node's `can_call_into_js()`.) Any thread; meaningful on the JS thread. + pub fn script_allowed(&self) -> bool { + self.0.hot.state.load(Ordering::Acquire) == State::Open as u8 + } + + /// Teardown, JS thread, after children are joined and before queued work + /// is released: refuse every future post/wake/ref/borrow and wait until no + /// thread is inside one. After this returns nothing off-thread can reach + /// the VM; whatever was posted before is in the queues for the teardown to + /// release. + pub(crate) fn close(&self) { + self.assert_js_thread(); + self.0 + .hot + .state + .store(State::Closed as u8, Ordering::SeqCst); + refusal_gate::closed(self); + if self.0.active.load(Ordering::SeqCst) != 0 { + self.0.drained.0.lock(); + while self.0.active.load(Ordering::SeqCst) != 0 { + self.0.drained.1.wait(&self.0.drained.0); + } + self.0.drained.0.unlock(); + } + // SAFETY: JS thread; no accessor can be inside any more. + unsafe { *self.0.hot.vm.get() = core::ptr::null_mut() }; + } +} + +// ── Test suite only: deterministic refusals ─────────────────────────────── +// +// `BUN_DEBUG_TEST_WORKER_REFUSAL_GATE` (worker VMs; builds with debug +// assertions): a post from another thread — unless counted work is +// outstanding, whose producer must post before its count can return — waits +// until this handle is closed and only then proceeds, so it is refused with the +// real preconditions (the JS side already released, the handle really closed) +// and the producer's own release path runs every time rather than only when it +// happens to lose the race with teardown. Each refusal is named on stderr. +#[cfg(debug_assertions)] +mod refusal_gate { + use super::{Ordering, State, VmHandle}; + + impl VmHandle { + pub(crate) fn park_posts_until_closed(&self) { + self.0.park_posts.store(true, Ordering::Relaxed); + } + fn posts_parked(&self) -> bool { + self.0.park_posts.load(Ordering::Relaxed) + } + } + + pub(super) fn before_post(h: &VmHandle) { + if !h.posts_parked() + || std::thread::current().id() == h.0.js_thread + || h.0.embedded.load(Ordering::SeqCst) != 0 + { + return; + } + // Not holding `active` here: close() waits for that to drain. + h.0.drained.0.lock(); + while h.0.hot.state.load(Ordering::SeqCst) != State::Closed as u8 { + h.0.drained.1.wait(&h.0.drained.0); + } + h.0.drained.0.unlock(); + } + + /// close(), after publishing Closed: parked posts go now (and are refused). + pub(super) fn closed(h: &VmHandle) { + if h.posts_parked() { + h.0.drained.0.lock(); + h.0.drained.1.notify_all(); + h.0.drained.0.unlock(); + } + } + + pub(super) fn refused(h: &VmHandle, what: core::fmt::Arguments<'_>) { + if h.posts_parked() { + let w = bun_core::output::error_writer(); + let _ = writeln!(w, "[vm_handle] refused {what}"); + let _ = w.flush(); + } + } +} +#[cfg(not(debug_assertions))] +mod refusal_gate { + use super::VmHandle; + #[inline(always)] + pub(super) fn before_post(_: &VmHandle) {} + #[inline(always)] + pub(super) fn closed(_: &VmHandle) {} + #[inline(always)] + pub(super) fn refused(_: &VmHandle, _: core::fmt::Arguments<'_>) {} +} + +// ── C++ holds counted references to a handle ───────────────────────────── +// +// One representation crosses the FFI: `*const Shared`, a strong count on the +// Arc every `VmHandle` clone points at (`BunVmHandleRef` in C++). Long-lived +// holders (JSVMClientData, EventLoopTaskNoContext, NapiEnv) `retain` one and +// `release` it; a call that merely uses a reference someone else holds borrows +// it for the duration ([`VmHandle::borrow_ref`]). Nothing is boxed. + +/// A `VmHandle` view over a reference C++ holds, for the duration of one call: +/// the count stays C++'s. +pub struct BorrowedRef(core::mem::ManuallyDrop); +impl core::ops::Deref for BorrowedRef { + type Target = VmHandle; + fn deref(&self) -> &VmHandle { + &self.0 + } +} + +impl VmHandle { + /// Hand C++ one strong count on this handle. + pub fn into_ref(self) -> *const Shared { + Arc::into_raw(self.0) + } + + /// # Safety + /// `r` is a live reference obtained from [`VmHandle::into_ref`] (directly or + /// via `Bun__VmHandle__retain*`) that its holder keeps for the duration. + pub unsafe fn borrow_ref(r: *const Shared) -> BorrowedRef { + // SAFETY: fn contract; ManuallyDrop leaves the holder's count untouched. + BorrowedRef(core::mem::ManuallyDrop::new(VmHandle(unsafe { + Arc::from_raw(r) + }))) + } + + /// # Safety + /// `r` came from [`VmHandle::into_ref`] and its holder gives the count up here. + pub unsafe fn from_ref(r: *const Shared) -> VmHandle { + // SAFETY: fn contract. + VmHandle(unsafe { Arc::from_raw(r) }) + } +} + +/// JS thread: a reference to `vm`'s handle for C++ to keep (`release` when done). +#[unsafe(no_mangle)] +pub extern "C" fn Bun__VmHandle__retain(vm: &VirtualMachine) -> *const Shared { + vm.handle().into_ref() +} + +/// Any thread: one more reference on the same handle (for something that may +/// outlive whoever it got the reference from). +/// +/// # Safety +/// `r` is a live reference its holder keeps for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Bun__VmHandle__retainRef(r: *const Shared) -> *const Shared { + // SAFETY: fn contract. + unsafe { VmHandle::borrow_ref(r) }.clone().into_ref() +} + +/// Any thread: give a reference up. +/// +/// # Safety +/// `r` came from `Bun__VmHandle__retain*` and is not used afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Bun__VmHandle__release(r: *const Shared) { + // SAFETY: fn contract. + drop(unsafe { VmHandle::from_ref(r) }); +} + +/// Any thread: post a C++ task through a reference and give the reference up +/// (queued, or deleted unrun if the VM is gone). For a caller that took the +/// reference only to keep the VM reachable past a lock it was about to drop. +/// +/// # Safety +/// `r` came from `Bun__VmHandle__retain*` and is not used afterwards; `task` is +/// a live heap `WebCore::EventLoopTask` handed over. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Bun__VmHandle__postAndRelease( + r: *const Shared, + task: *mut crate::cpp_task::CppTask, +) { + // SAFETY: fn contract. + let handle = unsafe { VmHandle::from_ref(r) }; + // SAFETY: fn contract. + unsafe { handle.post_cpp_task(task) }; +} + +/// JS thread: adjust this VM's keep-alive directly (balanced pairs from +/// MessagePort / BroadcastChannel / ScriptExecutionContext stay balanced through +/// teardown; the cross-thread route below stops applying once the VM closes). +#[unsafe(no_mangle)] +pub extern "C" fn Bun__eventLoop__refKeepAlive(vm: &VirtualMachine, delta: core::ffi::c_int) { + if delta > 0 { + vm.event_loop_shared().ref_keep_alive(); + } else { + vm.event_loop_shared().unref_keep_alive(); + } +} + +/// Any thread: adjust the VM's keep-alive (no-op once the VM is closed). +/// +/// # Safety +/// `r` is a live reference its holder keeps for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Bun__VmHandle__refKeepAlive(r: *const Shared, delta: core::ffi::c_int) { + // SAFETY: fn contract. + let handle = unsafe { VmHandle::borrow_ref(r) }; + if delta > 0 { + handle.ref_keep_alive(LoopKind::Regular); + } else { + handle.unref_keep_alive(LoopKind::Regular); + } +} + +/// Any thread: Node's `can_call_into_js()` — false once the VM's stop was +/// requested (a parent's terminate(), the worker's own exit, teardown). +/// +/// # Safety +/// `r` is a live reference its holder keeps for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Bun__VmHandle__scriptAllowed(r: *const Shared) -> bool { + // SAFETY: fn contract. + unsafe { VmHandle::borrow_ref(r) }.script_allowed() +} + +/// The address of this handle's state byte, for C++ to test +/// `*addr == BUN_VM_HANDLE_STATE_OPEN` inline on its native→JS entries instead +/// of calling out per callback. Valid as long as the reference is held. +/// +/// # Safety +/// `r` is a live reference. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Bun__VmHandle__stateAddress(r: *const Shared) -> *const AtomicU8 { + // SAFETY: fn contract; `hot.state` lives in the Arc payload `r` points at. + unsafe { &raw const (*r).hot.state } +} + +// C++ (BunClientData.h) hard-codes this value. +const _: () = assert!(State::Open as u8 == 0); + +// ── Producers that serve either a JS VM or a MiniEventLoop ──────────────── +// +// fs.cp (also used by the shell), shell builtins, password hashing, zlib run +// on the work pool for whichever loop created them. For the JS case the +// completion goes through the VM's handle; a MiniEventLoop (bundler / shell / +// install threads) is owned by its thread and outlives the work it schedules, +// so its concurrent queue is posted to directly, as before. + +/// Where an off-thread completion goes: a JS VM (through its handle) or a +/// mini event loop. Captured on the owning thread when the work is created. +#[derive(Clone)] +pub enum ConcurrentPoster { + /// Erased handle of the JS loop's VM (obtained from the `EventLoopHandle` + /// itself, so it is correct whichever thread constructs the poster). + Js(bun_event_loop::JsPoster), + Mini(bun_ptr::BackRef), +} + +impl ConcurrentPoster { + /// From an `EventLoopHandle`: the JS arm asks the loop for its VM's poster + /// (a JS-thread-owned handle knows its VM); the mini arm posts directly. + pub fn from_event_loop_handle(h: &bun_event_loop::EventLoopHandle) -> Self { + match h { + bun_event_loop::EventLoopHandle::Js { owner } => { + ConcurrentPoster::Js(owner.js_poster()) + } + bun_event_loop::EventLoopHandle::Mini(mini) => ConcurrentPoster::Mini(*mini), + } + } + + pub fn is_js(&self) -> bool { + matches!(self, ConcurrentPoster::Js(..)) + } + + /// JS arm: count embedded work on the VM (see `VmHandle`). A mini loop is + /// owned by its thread and outlives its work, so there is nothing to count. + pub fn embedded_work_scheduled(&self) { + if let ConcurrentPoster::Js(p) = self { + p.embedded_work_scheduled(); + } + } + pub fn embedded_work_finished(&self) { + if let ConcurrentPoster::Js(p) = self { + p.embedded_work_finished(); + } + } + + /// Post a JS-loop `ConcurrentTask`. `Refused` ⇒ VM torn down, caller + /// releases. Panics (debug) if this poster is `Mini`. + pub fn post_js(&self, task: NonNull) -> Posted { + match self { + ConcurrentPoster::Js(p) => p.post(task), + ConcurrentPoster::Mini(_) => { + debug_assert!(false, "post_js on a Mini poster"); + Posted::Refused(task) + } + } + } + + /// Post a mini-loop task (always accepted; the mini loop outlives its work). + pub fn post_mini( + &self, + task: NonNull, + ) { + match self { + ConcurrentPoster::Mini(mini) => { + let mut mini = *mini; + // SAFETY: per `EventLoopHandle::Mini` invariant — the mini loop is + // alive for as long as work it created runs; its concurrent queue + // push is thread-safe. + unsafe { mini.get_mut() }.enqueue_task_concurrent(task); + } + ConcurrentPoster::Js(..) => debug_assert!(false, "post_mini on a Js poster"), + } + } +} + +// ── Erased form for crates below bun_jsc (spawn, bundler) ───────────────── + +struct PosterData { + handle: VmHandle, + kind: LoopKind, +} + +unsafe fn poster_post(data: *const (), task: NonNull) -> Posted { + // SAFETY: `data` is a leaked `Arc` pointer (see `to_js_poster`). + let d = unsafe { &*data.cast::() }; + d.handle.post(d.kind, task) +} +unsafe fn poster_clone(data: *const ()) -> *const () { + // SAFETY: as above; bump the Arc count and hand out the same pointer. + unsafe { Arc::increment_strong_count(data.cast::()) }; + data +} +unsafe fn poster_drop(data: *const ()) { + // SAFETY: as above; balances `into_raw`/`increment_strong_count`. + unsafe { drop(Arc::from_raw(data.cast::())) }; +} +unsafe fn poster_embedded_scheduled(data: *const ()) { + // SAFETY: as `poster_post`. + unsafe { &*data.cast::() } + .handle + .embedded_work_scheduled(); +} +unsafe fn poster_embedded_finished(data: *const ()) { + // SAFETY: as `poster_post`. + unsafe { &*data.cast::() } + .handle + .embedded_work_finished(); +} +static POSTER_VTABLE: bun_event_loop::JsPosterVTable = bun_event_loop::JsPosterVTable { + post: poster_post, + embedded_work_scheduled: poster_embedded_scheduled, + embedded_work_finished: poster_embedded_finished, + clone: poster_clone, + drop: poster_drop, +}; + +impl VmHandle { + /// An erased poster for `kind`, for code that cannot name `VmHandle`. + pub fn to_js_poster(&self, kind: LoopKind) -> bun_event_loop::JsPoster { + let data = Arc::into_raw(Arc::new(PosterData { + handle: self.clone(), + kind, + })) + .cast::<()>(); + // SAFETY: `data`/vtable pair as documented on `JsPoster::from_raw`. + unsafe { bun_event_loop::JsPoster::from_raw(data, &POSTER_VTABLE) } + } +} + +impl VirtualMachine { + /// JS thread: an erased poster for the current loop of this VM. + pub fn js_poster(&self) -> bun_event_loop::JsPoster { + self.loop_handle().to_js_poster() + } +} + +// ── LoopHandle: "where this job's completion goes" ──────────────────────── + +/// A [`VmHandle`] plus the loop of that VM the completion belongs on — what a +/// job created on the JS thread captures (`vm.loop_handle()`) and posts back +/// through from whatever thread finishes it. +#[derive(Clone)] +pub struct LoopHandle { + vm: VmHandle, + kind: LoopKind, +} + +/// A job that finishes off the JS thread and is posted back to its VM as a +/// `ConcurrentTask` — see [`post_job`]. It says where its [`LoopHandle`] lives +/// and how to release itself when the VM is already gone. There is +/// deliberately no default for the release: a job that cannot release itself +/// does not compile. +pub trait Postable: bun_event_loop::Taskable + Sized { + /// The handle captured at creation (`vm.loop_handle()`), stored in the job. + /// + /// # Safety + /// `this` is live. + unsafe fn loop_handle(this: *mut Self) -> *const LoopHandle; + + /// The `ConcurrentTask` that carries `this`: a fresh heap one by default; + /// jobs with an embedded task return that instead. + /// + /// # Safety + /// `this` is live. + unsafe fn concurrent_task(this: *mut Self) -> NonNull { + ConcurrentTaskItem::create_from(this) + } + + /// The VM refused the completion (torn down). Runs on the posting thread, + /// usually *not* the JS thread: free what the job owns, do not touch JSC + /// handles (they die with the VM), and free the allocation itself. + /// + /// # Safety + /// `this` is the live job; nothing uses it afterwards. + unsafe fn release_refused(this: *mut Self); +} + +/// Post a finished job's completion back to the VM it came from. If that VM +/// has been torn down, the job releases itself here; callers have nothing to +/// check either way. +/// +/// # Safety +/// `job` is a live heap job whose off-thread part is finished; the caller does +/// not touch it afterwards (it now belongs to the VM's queue, or was released). +pub unsafe fn post_job(job: *mut T) { + // Clone the handle out first: a refusal frees `job`, handle field included. + // SAFETY: fn contract. + let handle = unsafe { (*T::loop_handle(job)).clone() }; + // SAFETY: fn contract. + let task = unsafe { T::concurrent_task(job) }; + if let Posted::Refused(task) = handle.post_task(task) { + refusal_gate::refused( + &handle.vm, + format_args!("job: {}", core::any::type_name::()), + ); + // SAFETY: handed back unqueued; `job` per fn contract. + unsafe { + ConcurrentTaskItem::release_refused(task); + T::release_refused(job); + } + } +} + +impl LoopHandle { + /// Post an already-built task. Prefer [`post_job`], which leaves the caller + /// nothing to check; this hands a refusal back. + pub fn post_task(&self, task: NonNull) -> Posted { + self.vm.post(self.kind, task) + } + pub fn borrow(&self) -> Option { + self.vm.borrow() + } + pub fn borrow_if_running(&self) -> Option { + self.vm.borrow_if_running() + } + pub fn accepting_work(&self) -> bool { + self.vm.accepting_work() + } + pub fn embedded_work_scheduled(&self) { + self.vm.embedded_work_scheduled() + } + pub fn embedded_work_finished(&self) { + self.vm.embedded_work_finished() + } + pub fn ref_keep_alive(&self) { + self.vm.ref_keep_alive(self.kind) + } + pub fn unref_keep_alive(&self) { + self.vm.unref_keep_alive(self.kind) + } + /// An erased poster for this loop, for code that cannot name `bun_jsc`. + pub fn to_js_poster(&self) -> bun_event_loop::JsPoster { + self.vm.to_js_poster(self.kind) + } +} + +impl VirtualMachine { + /// This VM's live pool jobs. JS thread only. + #[allow(clippy::mut_from_ref)] + #[inline] + pub fn jobs(&self) -> &mut crate::job::JobList { + // SAFETY: JS-thread-only intrusive list; callers never hold two at once + // (each call is a single push/unlink/release statement). + unsafe { &mut *self.jobs.get() } + } + + /// JS thread: the handle a new job captures — this VM, and the loop it is + /// currently ticking (regular, macro, or a spawnSync isolated loop). + pub fn loop_handle(&self) -> LoopHandle { + LoopHandle { + vm: self.handle(), + kind: self.current_loop_kind(), + } + } +} + +// ── Isolated event loops (Bun.spawnSync) ────────────────────────────────── +// +// spawnSync runs a third, heap-allocated `EventLoop` on the JS thread while it +// blocks; process exits (waiter thread) and pool completions for that call +// must land on *its* concurrent queue, not the VM's. It gets its own small +// poster with the same gate discipline, closed before the loop is freed. + +/// Opaque outside this crate: the poster of a spawnSync isolated loop. +pub struct IsolatedPosterInner { + open: core::sync::atomic::AtomicBool, + active: AtomicU32, + event_loop: *const EventLoop, +} +// SAFETY: `event_loop` is dereferenced only under the gate (open && counted). +unsafe impl Send for IsolatedPosterInner {} +// SAFETY: as above. +unsafe impl Sync for IsolatedPosterInner {} + +impl IsolatedPosterInner { + pub(crate) fn new(event_loop: *const EventLoop) -> Arc { + Arc::new(Self { + open: core::sync::atomic::AtomicBool::new(true), + active: AtomicU32::new(0), + event_loop, + }) + } + + /// JS thread, before the isolated loop is freed: refuse further posts and + /// wait out anyone mid-post. + pub(crate) fn close(&self) { + self.open.store(false, Ordering::SeqCst); + while self.active.load(Ordering::SeqCst) != 0 { + core::hint::spin_loop(); + } + } + + pub(crate) fn post(&self, task: NonNull) -> Posted { + self.active.fetch_add(1, Ordering::SeqCst); + let open = self.open.load(Ordering::SeqCst); + if open { + // SAFETY: gate held and open ⇒ the isolated loop is alive. + let el = unsafe { &*self.event_loop }; + el.concurrent_tasks.push(task); + el.wakeup(); + } + self.active.fetch_sub(1, Ordering::SeqCst); + if open { + Posted::Queued + } else { + Posted::Refused(task) + } + } + + pub(crate) fn to_js_poster(this: &Arc) -> bun_event_loop::JsPoster { + let data = Arc::into_raw(Arc::clone(this)).cast::<()>(); + // SAFETY: data/vtable pair per `JsPoster::from_raw`. + unsafe { bun_event_loop::JsPoster::from_raw(data, &ISOLATED_POSTER_VTABLE) } + } +} + +unsafe fn isolated_post(data: *const (), task: NonNull) -> Posted { + // SAFETY: leaked Arc. + unsafe { &*data.cast::() }.post(task) +} +unsafe fn isolated_clone(data: *const ()) -> *const () { + // SAFETY: as above. + unsafe { Arc::increment_strong_count(data.cast::()) }; + data +} +unsafe fn isolated_drop(data: *const ()) { + // SAFETY: as above. + unsafe { drop(Arc::from_raw(data.cast::())) }; +} +// An isolated loop is driven to completion synchronously by its creator on +// the JS thread (spawnSync), so its VM cannot tear down under its work. +unsafe fn isolated_embedded_noop(_data: *const ()) {} +static ISOLATED_POSTER_VTABLE: bun_event_loop::JsPosterVTable = bun_event_loop::JsPosterVTable { + post: isolated_post, + embedded_work_scheduled: isolated_embedded_noop, + embedded_work_finished: isolated_embedded_noop, + clone: isolated_clone, + drop: isolated_drop, +}; diff --git a/src/jsc/WorkTask.rs b/src/jsc/WorkTask.rs deleted file mode 100644 index e9617030938d..000000000000 --- a/src/jsc/WorkTask.rs +++ /dev/null @@ -1,142 +0,0 @@ -use bun_event_loop::ConcurrentTask::{AutoDeinit, ConcurrentTask, TaskTag, Taskable}; -use bun_io::{self as Async, KeepAlive}; -use bun_threading::{IntrusiveWorkTask as _, WorkPoolTask, work_pool::WorkPool}; - -use crate::JSGlobalObject; -use crate::debugger::AsyncTaskTracker; -use crate::event_loop::EventLoop; -use bun_ptr::BackRef; - -/// A generic task that runs work on a thread pool and executes a callback on the main JavaScript thread. -/// Unlike ConcurrentPromiseTask which automatically resolves a Promise, WorkTask provides more flexibility -/// by allowing the Context to handle the result however it wants (e.g., calling callbacks, emitting events, etc.). -/// -/// The Context type must implement: -/// - `run(*mut Context, *mut WorkTask)` - performs the work on the thread pool -/// - `then(*mut Context, &JSGlobalObject)` - handles the result on the JS thread (no automatic Promise resolution) -/// -/// Key differences from ConcurrentPromiseTask: -/// - No automatic Promise creation or resolution -/// - Includes async task tracking for debugging -/// - More flexible result handling via the `then` callback -/// - Context receives a reference to the WorkTask itself in the `run` method -pub trait WorkTaskContext: Sized { - /// Tag this `WorkTask` carries when enqueued back onto the JS event - /// loop's concurrent queue (`task_tag::*`). - const TASK_TAG: TaskTag; - - /// Perform the work on the thread pool. `this`/`task` are raw pointers - /// because the context is heap-allocated, crosses threads, and is mutated. - fn run(this: *mut Self, task: *mut WorkTask); - fn then(this: *mut Self, global_this: &JSGlobalObject) -> Result<(), crate::JsTerminated>; -} - -pub struct WorkTask { - pub ctx: *mut Context, - pub(crate) task: WorkPoolTask, - /// BACKREF — captured from the JS-thread VM at create time; the VM (and its - /// `EventLoop`) outlives every task scheduled on it. - pub(crate) event_loop: BackRef, - // allocator field dropped — global mimalloc (see PORTING.md §Allocators) - pub global_this: BackRef, - pub(crate) concurrent_task: ConcurrentTask, - pub(crate) async_task_tracker: AsyncTaskTracker, - - // This is a poll because we want it to enter the uSockets loop - pub ref_: KeepAlive, -} - -bun_threading::intrusive_work_task!([Context: WorkTaskContext] WorkTask, task); - -// SAFETY: `WorkTask` is moved into the thread pool's queue (intrusive `task` -// node) and back via the concurrent task queue. All access to `ctx` / -// `global_this` is sequenced by the work-pool → on_finish → run_from_js -// hand-off; raw pointers are inert. -unsafe impl Send for WorkTask {} - -impl Taskable for WorkTask { - const TAG: TaskTag = Context::TASK_TAG; -} - -impl WorkTask { - pub fn create_on_js_thread(global_this: &JSGlobalObject, value: *mut Context) -> *mut Self { - let vm = global_this.bun_vm().as_mut(); - let event_loop = BackRef::new(vm.event_loop_shared()); - let mut this = Box::new(Self { - event_loop, - ctx: value, - global_this: BackRef::new(global_this), - task: WorkPoolTask { - node: Default::default(), - callback: Self::run_from_thread_pool, - }, - concurrent_task: ConcurrentTask::default(), - async_task_tracker: AsyncTaskTracker::init(vm), - ref_: KeepAlive::default(), - }); - this.ref_.ref_(Async::js_vm_ctx()); - - // The intrusive `task` field is recovered via container_of in - // run_from_thread_pool, so this must live at a stable heap address as a - // raw pointer. Paired with `heap::take` in `destroy`. - bun_core::heap::into_raw(this) - } - - // Not `impl Drop` — `ref_.unref` is also called from `run_from_js`, - // and `Self` is held as a raw pointer (intrusive task), so destruction - // is explicit. - pub unsafe fn destroy(this: *mut Self) { - // SAFETY: `this` was produced by heap::alloc in create_on_js_thread and - // has not been freed. - let mut this = unsafe { bun_core::heap::take(this) }; - this.ref_.unref(Async::js_vm_ctx()); - // drop(this) — Box freed at scope exit - } - - pub(crate) unsafe fn run_from_thread_pool(task: *mut WorkPoolTask) { - crate::mark_binding(); - // SAFETY: only reachable via `WorkPoolTask::callback` (unsafe-fn-ptr - // slot — safe-fn coerces) for the `task` field initialised in - // `create_on_js_thread`; the WorkPool calls back with exactly that - // field, so `from_task_ptr` recovers the live heap `Self` parent, - // exclusively owned by the work pool for this callback's duration. - // `ctx` is read through the recovered backref in the same audited scope. - let (this, ctx) = unsafe { - let this = Self::from_task_ptr(task); - (this, (*this).ctx) - }; - Context::run(ctx, this); - } - - pub fn run_from_js(this: &mut Self) -> Result<(), crate::JsTerminated> { - let ctx = this.ctx; - let tracker = this.async_task_tracker; - let global_this = this.global_this.get(); - this.ref_.unref(Async::js_vm_ctx()); - - let _dispatch = tracker.dispatch(global_this); - Context::then(ctx, global_this) - } - - pub fn schedule(this: &mut Self) { - this.ref_.ref_(Async::js_vm_ctx()); - this.async_task_tracker.did_schedule(this.global_this.get()); - WorkPool::schedule(&raw mut this.task); - } - - pub fn on_finish(this: &mut Self) { - // `concurrent_task` is an intrusive field of `*this`; `from` - // re-initializes it in place and returns the same address. Passing - // `this_ptr` while holding `&mut *this` is sound because `from` only - // stores the pointer (does not dereference it). - let event_loop = this.event_loop; - let this_ptr: *mut Self = this; - let task = core::ptr::NonNull::from( - this.concurrent_task - .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); - } -} diff --git a/src/jsc/any_task_job.rs b/src/jsc/any_task_job.rs deleted file mode 100644 index f77d2eecd2d2..000000000000 --- a/src/jsc/any_task_job.rs +++ /dev/null @@ -1,187 +0,0 @@ -use bun_event_loop::Task; -use bun_io::KeepAlive; -use bun_threading::work_pool::{IntrusiveWorkTask as _, Task as WorkPoolTask, WorkPool}; - -use crate::event_loop::ConcurrentTask; -use crate::{JSGlobalObject, JsResult, VirtualMachineRef as VirtualMachine}; - -/// Per-job payload trait. Implementors own the off-thread work body and the -/// JS-thread completion; the surrounding heap/queue/keep-alive plumbing is -/// supplied by [`AnyTaskJob`]. -/// -/// `Drop` on the implementor is the deinit path — it runs on the JS thread -/// (from `run_from_js`'s `heap::take`) on every exit, including the -/// `is_shutting_down` early-out and `init` failure. -pub trait AnyTaskJobCtx: Sized { - /// Optional fallible JS-thread setup, run after heap allocation but before - /// `schedule`. On error the job is freed (running `Drop`). Default: no-op. - #[inline] - fn init(&mut self, _global: &JSGlobalObject) -> JsResult<()> { - Ok(()) - } - - /// Work-pool body — runs OFF the JS thread. `global` is the creating VM's - /// `*mut JSGlobalObject` (raw, not `&` — most impls ignore it; the two - /// C++-backed ctxs forward it through FFI without dereferencing). - fn run(&mut self, global: *mut JSGlobalObject); - - /// JS-thread completion. Called once after `run` re-queues onto the event - /// loop, unless the VM is already shutting down. Any `Err` is surfaced as - /// the completion callback's result (i.e. propagated to the tick loop). - fn then(&mut self, global: &JSGlobalObject) -> JsResult<()>; -} - -/// Heap-allocated offload job; created via [`AnyTaskJob::create`] and freed in -/// `run_from_js` (or on `init` failure). `ctx` is `pub` so callers can read -/// e.g. a `JSPromiseStrong` field after scheduling. -#[repr(C)] -pub struct AnyTaskJob { - run_from_js_erased: fn(*mut ()) -> JsResult<()>, - release_erased: fn(*mut ()), - vm: bun_ptr::BackRef, - task: WorkPoolTask, - poll: KeepAlive, - pub ctx: C, -} - -/// `task_tag::AnyTaskJob` dispatch entry: read the erased completion at the -/// head of the allocation and call it with the whole-job pointer. -/// -/// # Safety -/// `ptr` must be a live `*mut AnyTaskJob` (for some `C`) produced by -/// [`AnyTaskJob::create`]; ownership transfers (the entry frees the job). -pub unsafe fn dispatch_erased(ptr: *mut ()) -> JsResult<()> { - // SAFETY: `AnyTaskJob` is `#[repr(C)]` with `run_from_js_erased` - // first; caller contract that `ptr` is such an allocation. - let entry = unsafe { *ptr.cast:: JsResult<()>>() }; - entry(ptr) -} - -/// Free a queued job at VM shutdown without running its completion; the ctx -/// `Drop` needs the still-live VM to release its JSC handles and resources. -/// -/// # Safety -/// `ptr` must be a live `*mut AnyTaskJob` from [`AnyTaskJob::create`], -/// popped from the event-loop queue (so it held exclusive ownership); frees it. -pub unsafe fn release_erased(ptr: *mut ()) { - // SAFETY: `AnyTaskJob` is `#[repr(C)]` with `release_erased` second; - // caller contract that `ptr` is such an allocation. - let entry = unsafe { *ptr.cast::().add(1) }; - entry(ptr) -} - -const _: () = assert!(core::mem::offset_of!(AnyTaskJob<()>, run_from_js_erased) == 0); -const _: () = assert!( - core::mem::offset_of!(AnyTaskJob<()>, release_erased) - == core::mem::size_of:: JsResult<()>>() -); - -impl bun_event_loop::Taskable for AnyTaskJob { - const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::AnyTaskJob; -} - -bun_threading::intrusive_work_task!([C] AnyTaskJob, task); - -impl Drop for AnyTaskJob { - #[inline] - fn drop(&mut self) { - // No-op while inactive (init-failure path never `ref_`ed). - self.poll.unref(bun_io::js_vm_ctx()); - // `ctx: C` drops after this via field drop glue. - } -} - -impl AnyTaskJob { - /// Heap-allocate, wire the intrusive `WorkPoolTask`, and run - /// [`AnyTaskJobCtx::init`]. On `init` error the allocation is freed - /// (running `Drop for C`). The returned pointer is owned by the caller - /// until handed to [`Self::schedule`]. - pub fn create(global: &JSGlobalObject, ctx: C) -> JsResult<*mut Self> { - let vm = bun_ptr::BackRef::new(global.bun_vm()); - let job = bun_core::heap::into_raw(Box::new(Self { - run_from_js_erased: |p| Self::run_from_js(p.cast::()), - release_erased: |p| Self::release(p.cast::()), - vm, - task: WorkPoolTask { - node: Default::default(), - callback: Self::run_task, - }, - poll: KeepAlive::default(), - ctx, - })); - // `ctx.init` may throw (e.g. CryptoJob); on error, reclaim the - // box so `Drop for C` releases any resources `ctx` already owns. - let mut guard = scopeguard::guard(job, |job| { - // SAFETY: `job` came from `heap::into_raw` above and was not consumed. - drop(unsafe { bun_core::heap::take(job) }); - }); - // SAFETY: `job` is exclusively owned here. - unsafe { (**guard).ctx.init(global)? }; - Ok(scopeguard::ScopeGuard::into_inner(guard)) - } - - /// `KeepAlive::ref_` the JS event loop and hand the intrusive task to the - /// work pool. Ownership transfers to the pool → `run_task` → - /// `run_from_js`. - /// - /// # Safety - /// `this` must be a live pointer returned by [`Self::create`] that has not - /// yet been scheduled. - pub unsafe fn schedule(this: *mut Self) { - // SAFETY: caller contract. `schedule` is a cross-thread handoff — a - // worker may run and free the job as soon as it's queued — so the - // pointer handed to the pool is derived from the raw `this` and nothing - // touches the job afterwards. - unsafe { (*this).poll.ref_(bun_io::js_vm_ctx()) }; - // SAFETY: `this` is live; the pointer handed to the pool is derived - // from the raw `this` and nothing touches the job after the schedule. - WorkPool::schedule(unsafe { &raw mut (*this).task }); - } - - /// [`Self::create`] + [`Self::schedule`]. For callers that don't need to - /// read back from `ctx` after scheduling. - pub fn create_and_schedule(global: &JSGlobalObject, ctx: C) -> JsResult<()> { - let job = Self::create(global, ctx)?; - // SAFETY: `job` is a freshly-created live pointer. - unsafe { Self::schedule(job) }; - Ok(()) - } - - /// `WorkPoolTask` callback — runs OFF the JS thread. - /// - /// Reachable only via the `WorkPoolTask::callback` fn-ptr slot (safe fn - /// coerces into it) for the `task` field initialised in [`Self::create`]; the - /// WorkPool calls back with exactly that field, so `from_task_ptr` - /// recovers the live heap `Self` parent (owned until `run_from_js` - /// reclaims it). Mirrors [`crate::WorkTask::run_from_thread_pool`]. - fn run_task(task: *mut WorkPoolTask) { - // SAFETY: only reachable via the `WorkPoolTask::callback` slot wired - // in `create`; `task` points to `Self.task` and the job is live until - // `run_from_js` reclaims it. - let job = unsafe { &mut *Self::from_task_ptr(task) }; - let vm = job.vm; - job.ctx.run(vm.global); - // `ConcurrentTask::create` heap-allocates a fresh task; the queue takes - // ownership of it. - vm.event_loop_shared() - .enqueue_task_concurrent(ConcurrentTask::create(Task::init(std::ptr::from_mut(job)))); - } - - fn run_from_js(this: *mut Self) -> JsResult<()> { - // SAFETY: `this` was produced by `heap::into_raw` in `create` and is - // uniquely owned here (the task fires exactly once). - let mut this = unsafe { bun_core::heap::take(this) }; - let vm = this.vm; - if vm.is_shutting_down() { - return Ok(()); - } - this.ctx.then(vm.global()) - } - - /// [`release_erased`]'s monomorphic body. - fn release(this: *mut Self) { - // SAFETY: `this` was produced by `heap::into_raw` in `create`; the - // caller (the popped queue entry) held exclusive ownership. - drop(unsafe { bun_core::heap::take(this) }); - } -} diff --git a/src/jsc/array_buffer.rs b/src/jsc/array_buffer.rs index de94e5c9d181..b60822eb4521 100644 --- a/src/jsc/array_buffer.rs +++ b/src/jsc/array_buffer.rs @@ -822,6 +822,15 @@ pub struct MarkedArrayBuffer { pub pinned: bool, } +/// Bytes produced off-thread (`from_bytes`/`from_string`) are owned until they +/// are handed to JSC; a result that is never converted (its VM went away, the +/// conversion path bailed) frees them here. +impl Drop for MarkedArrayBuffer { + fn drop(&mut self) { + self.destroy(); + } +} + impl MarkedArrayBuffer { pub fn from_typed_array(ctx: &JSGlobalObject, value: JSValue) -> MarkedArrayBuffer { MarkedArrayBuffer { @@ -889,8 +898,8 @@ impl MarkedArrayBuffer { } /// Releases the owned byte buffer if this `MarkedArrayBuffer` was created with an - /// allocator (e.g. via `from_string`/`from_bytes`). Does not free the struct itself; - /// `MarkedArrayBuffer` is passed and stored by value, so callers own its storage. + /// allocator (e.g. via `from_string`/`from_bytes`) and never handed to JSC. + /// Idempotent; also what `Drop` does. pub fn destroy(&mut self) { if self.owns_buffer { self.owns_buffer = false; @@ -899,18 +908,22 @@ impl MarkedArrayBuffer { } } - pub fn to_node_buffer(&self, global: &JSGlobalObject) -> JSValue { + /// Ownership of the bytes moves to JSC (freed by the buffer's deallocator). + pub fn to_node_buffer(&mut self, global: &JSGlobalObject) -> JsResult { // `JSValue::create_buffer` takes `&mut [u8]` (ownership transfers to JSC // via the deallocator). `ArrayBuffer` is `Copy` over a raw pointer, so // copy the descriptor and project a mutable slice. + self.owns_buffer = false; let mut buf = self.buffer; JSValue::create_buffer(global, buf.byte_slice_mut()) } - pub fn to_js(&self, global: &JSGlobalObject) -> JsResult { + /// Ownership of the bytes moves to JSC (freed by `MarkedArrayBuffer_deallocator`). + pub fn to_js(&mut self, global: &JSGlobalObject) -> JsResult { if !self.buffer.value.is_empty_or_undefined_or_null() { return Ok(self.buffer.value); } + self.owns_buffer = false; if self.buffer.byte_len == 0 { // SAFETY: null `ptr` with `len == 0` and no deallocator — every // obligation of the callee's contract holds trivially. diff --git a/src/jsc/bindings/ActiveDOMCallback.cpp b/src/jsc/bindings/ActiveDOMCallback.cpp index 8c07f06c836a..78a2adb6042d 100644 --- a/src/jsc/bindings/ActiveDOMCallback.cpp +++ b/src/jsc/bindings/ActiveDOMCallback.cpp @@ -45,7 +45,7 @@ ActiveDOMCallback::~ActiveDOMCallback() = default; bool ActiveDOMCallback::canInvokeCallback() const { ScriptExecutionContext* context = scriptExecutionContext(); - return context && !context->activeDOMObjectsAreSuspended() && !context->activeDOMObjectsAreStopped(); + return context && !context->activeDOMObjectsAreStopped(); } } // namespace WebCore diff --git a/src/jsc/bindings/BunClientData.cpp b/src/jsc/bindings/BunClientData.cpp index 4eebfd1559ac..c538322f8e39 100644 --- a/src/jsc/bindings/BunClientData.cpp +++ b/src/jsc/bindings/BunClientData.cpp @@ -93,13 +93,24 @@ void JSVMClientData::JSHeapDataDeleter::operator()(JSHeapData* heapData) const JSVMClientData::~JSVMClientData() { + while (!m_clients.isEmpty()) { + auto* client = &*m_clients.begin(); + client->remove(); + client->willDestroyVM(); + } + m_normalWorld = nullptr; + if (vmHandle) + Bun__VmHandle__release(std::exchange(vmHandle, nullptr)); } -void JSVMClientData::create(VM* vm, void* bunVM) +void JSVMClientData::create(VM* vm, void* bunVM, bool isWorkerVM) { auto provider = WebCore::createBuiltinsSourceProvider(); JSVMClientData* clientData = new JSVMClientData(*vm, provider); clientData->bunVM = bunVM; + clientData->m_isWorkerVM = isWorkerVM; + clientData->vmHandle = Bun__VmHandle__retain(bunVM); + clientData->vmHandleState = Bun__VmHandle__stateAddress(clientData->vmHandle); vm->deferredWorkTimer->onAddPendingWork = [clientData](Ref&& ticket, JSC::DeferredWorkTimer::WorkType kind) -> void { Bun::JSCTaskScheduler::onAddPendingWork(clientData, WTF::move(ticket), kind); }; diff --git a/src/jsc/bindings/BunClientData.h b/src/jsc/bindings/BunClientData.h index 201903d40e4f..21a163421c03 100644 --- a/src/jsc/bindings/BunClientData.h +++ b/src/jsc/bindings/BunClientData.h @@ -1,5 +1,32 @@ #pragma once +// A counted reference to a VM's handle (bun_jsc::VmHandle): what any thread other than the +// VM's own uses to post work to it, keep its loop alive, or ask whether it may still run +// script. retain / retainRef take a count, release gives one up; valid however long it is held. +struct BunVmHandleRef; +extern "C" const BunVmHandleRef* Bun__VmHandle__retain(void* bunVM); // JS thread +extern "C" const BunVmHandleRef* Bun__VmHandle__retainRef(const BunVmHandleRef*); // any thread +extern "C" void Bun__VmHandle__release(const BunVmHandleRef*); +namespace WebCore { +class EventLoopTask; +} +// Post through a reference and give it up in one step (a reference taken only to outlive a lock). +extern "C" void Bun__VmHandle__postAndRelease(const BunVmHandleRef*, WebCore::EventLoopTask*); +extern "C" void Bun__VmHandle__refKeepAlive(const BunVmHandleRef*, int delta); +// Node's can_call_into_js(): false once the VM's stop was requested (terminate()/exit/teardown). Any thread. +extern "C" bool Bun__VmHandle__scriptAllowed(const BunVmHandleRef*); +// The handle's state byte, so hot paths test it inline (BUN_VM_HANDLE_STATE_OPEN == bun_jsc::vm_handle::State::Open). +extern "C" const unsigned char* Bun__VmHandle__stateAddress(const BunVmHandleRef*); +#define BUN_VM_HANDLE_STATE_OPEN 0 +#include +inline bool Bun__VmHandle__scriptAllowedInline(const unsigned char* state) +{ + // Rust's AtomicU8 has the layout of u8; a relaxed load pairs with its stores. + return reinterpret_cast*>(state)->load(std::memory_order_relaxed) == BUN_VM_HANDLE_STATE_OPEN; +} +// JS thread only: adjust the keep-alive of the VM this thread runs. +extern "C" void Bun__eventLoop__refKeepAlive(void* bunVM, int delta); + namespace WebCore { class ExtendedDOMClientIsoSubspaces; @@ -10,6 +37,7 @@ class DOMWrapperWorld; } #include "root.h" +#include #include "ExtendedDOMClientIsoSubspaces.h" #include "ExtendedDOMIsoSubspaces.h" @@ -17,11 +45,13 @@ class DOMWrapperWorld; #include "BunBuiltinNames.h" // #include "WebCoreJSBuiltins.h" // #include "WorkerThreadType.h" +#include #include #include #include #include #include +#include #include "JSCTaskScheduler.h" #include "HTTPHeaderIdentifiers.h" namespace Zig { @@ -82,6 +112,19 @@ class JSHeapData { DECLARE_ALLOCATOR_WITH_HEAP_IDENTIFIER(JSVMClientData); +// WebCore's JSVMClientDataClient: something holding JSC::Weak<> handles into a VM whose C++ owner +// can outlive that VM (a JSEventListener on an AbortSignal an in-flight request holds, or on a +// MessagePort in transit) registers here so ~JSVMClientData can tell it to let go first +// (willDestroyVM). As upstream, only worker VMs' clients register — the main VM lives for the +// process. Unlike upstream (WeakHashSet), Bun links clients intrusively: two pointer stores to +// register, and the client unlinks itself in its destructor (VM thread, which the Weak<> handles +// already require). +class JSVMClientDataClient : public BasicRawSentinelNode { +public: + virtual ~JSVMClientDataClient() = default; + virtual void willDestroyVM() = 0; +}; + class JSVMClientData : public JSC::VM::ClientData { WTF_MAKE_NONCOPYABLE(JSVMClientData); WTF_DEPRECATED_MAKE_FAST_ALLOCATED_WITH_HEAP_IDENTIFIER(JSVMClientData, JSVMClientData); @@ -91,7 +134,7 @@ class JSVMClientData : public JSC::VM::ClientData { virtual ~JSVMClientData(); - static void create(JSC::VM*, void*); + static void create(JSC::VM*, void* bunVM, bool isWorkerVM); JSHeapData& heapData() { return *m_heapData; } BunBuiltinNames& builtinNames() { return m_builtinNames; } @@ -116,6 +159,13 @@ class JSVMClientData : public JSC::VM::ClientData { WebCore::HTTPHeaderIdentifiers& httpHeaderIdentifiers() { return m_httpHeaderIdentifiers; } void* bunVM; + // Opaque box of the Rust VmHandle for this VM: what any *other* thread uses + // to post work / ref the loop (never bunVM). Created in create(), released + // in the destructor; valid however long C++ holds it. + const ::BunVmHandleRef* vmHandle { nullptr }; + // vmHandle's state byte (Bun__VmHandle__stateAddress): the per-callback "may run script" test is one load. + const unsigned char* vmHandleState { nullptr }; + ALWAYS_INLINE bool scriptAllowed() const { return Bun__VmHandle__scriptAllowedInline(vmHandleState); } Bun::JSCTaskScheduler deferredWorkTimer; // Linked list of StrongRootBlock cells backing bun_jsc::Strong handles @@ -168,6 +218,15 @@ class JSVMClientData : public JSC::VM::ClientData { std::unique_ptr m_clientSubspaces; WebCore::HTTPHeaderIdentifiers m_httpHeaderIdentifiers; + + SentinelLinkedList> m_clients; + bool m_isWorkerVM { false }; + +public: + // upstream's `&vm != commonVMOrNull()` + bool isWorkerVM() const { return m_isWorkerVM; } + // VM thread. Unlinking is the client's own (`remove()` in its destructor). + void addClient(JSVMClientDataClient& client) { m_clients.append(&client); } }; } // namespace WebCore diff --git a/src/jsc/bindings/BunDebugger.cpp b/src/jsc/bindings/BunDebugger.cpp index c5a46de3013c..4dc13cd2a11a 100644 --- a/src/jsc/bindings/BunDebugger.cpp +++ b/src/jsc/bindings/BunDebugger.cpp @@ -23,7 +23,6 @@ #include "InspectorHTTPServerAgent.h" extern "C" void Bun__tickWhilePaused(bool*); -extern "C" void Bun__eventLoop__incrementRefConcurrently(void* bunVM, int delta); namespace Bun { using namespace JSC; @@ -127,7 +126,7 @@ class BunInspectorConnection : public ThreadSafeRefCountedstatus = ConnectionStatus::Connected; auto* globalObject = context.jsGlobalObject(); if (this->unrefOnDisconnect) { - Bun__eventLoop__incrementRefConcurrently(static_cast(globalObject)->bunVM(), 1); + Bun__VmHandle__refKeepAlive(WebCore::clientData(JSC::getVM(globalObject))->vmHandle, 1); } globalObject->setInspectable(true); auto& inspector = globalObject->inspectorDebuggable(); @@ -214,7 +213,7 @@ class BunInspectorConnection : public ThreadSafeRefCountedunrefOnDisconnect) { connection->unrefOnDisconnect = false; - Bun__eventLoop__incrementRefConcurrently(static_cast(context.jsGlobalObject())->bunVM(), -1); + Bun__VmHandle__refKeepAlive(WebCore::clientData(context.vm())->vmHandle, -1); } { diff --git a/src/jsc/bindings/DOMFormData.h b/src/jsc/bindings/DOMFormData.h index d38b38f48073..2ce62a3eba90 100644 --- a/src/jsc/bindings/DOMFormData.h +++ b/src/jsc/bindings/DOMFormData.h @@ -49,6 +49,10 @@ class DOMFormData : public RefCounted, public ContextDestructionObs WTF_DEPRECATED_MAKE_FAST_ALLOCATED_WITH_HEAP_IDENTIFIER(DOMFormData, DOMFormData); public: + // ContextDestructionObserver. + void ref() const final { RefCounted::ref(); } + void deref() const final { RefCounted::deref(); } + using FormDataEntryValue = std::variant>; struct Item { diff --git a/src/jsc/bindings/EventLoopTaskNoContext.cpp b/src/jsc/bindings/EventLoopTaskNoContext.cpp index 6f1f3d367ad9..d2590245b5c7 100644 --- a/src/jsc/bindings/EventLoopTaskNoContext.cpp +++ b/src/jsc/bindings/EventLoopTaskNoContext.cpp @@ -7,9 +7,9 @@ extern "C" void Bun__EventLoopTaskNoContext__performTask(EventLoopTaskNoContext* task->performTask(); } -extern "C" void* Bun__EventLoopTaskNoContext__createdInBunVm(const EventLoopTaskNoContext* task) +extern "C" const ::BunVmHandleRef* Bun__EventLoopTaskNoContext__vmHandle(const EventLoopTaskNoContext* task) { - return task->createdInBunVm(); + return task->vmHandle(); } } // namespace Bun diff --git a/src/jsc/bindings/EventLoopTaskNoContext.h b/src/jsc/bindings/EventLoopTaskNoContext.h index fede33f2603c..9052ce618014 100644 --- a/src/jsc/bindings/EventLoopTaskNoContext.h +++ b/src/jsc/bindings/EventLoopTaskNoContext.h @@ -2,6 +2,7 @@ #include "ZigGlobalObject.h" #include "root.h" +#include "BunClientData.h" namespace Bun { @@ -11,25 +12,31 @@ class EventLoopTaskNoContext { public: EventLoopTaskNoContext(JSC::JSGlobalObject* globalObject, Function&& task) - : m_createdInBunVm(defaultGlobalObject(globalObject)->bunVM()) + : m_vmHandle(Bun__VmHandle__retainRef(WebCore::clientData(JSC::getVM(globalObject))->vmHandle)) , m_task(WTF::move(task)) { } + ~EventLoopTaskNoContext() + { + Bun__VmHandle__release(m_vmHandle); + } + void performTask() { m_task(); delete this; } - void* createdInBunVm() const { return m_createdInBunVm; } + // A reference to the creating VM's handle, since a pool task can outlive that VM. + const ::BunVmHandleRef* vmHandle() const { return m_vmHandle; } private: - void* m_createdInBunVm; + const ::BunVmHandleRef* m_vmHandle; Function m_task; }; extern "C" void Bun__EventLoopTaskNoContext__performTask(EventLoopTaskNoContext* task); -extern "C" void* Bun__EventLoopTaskNoContext__createdInBunVm(const EventLoopTaskNoContext* task); +extern "C" const ::BunVmHandleRef* Bun__EventLoopTaskNoContext__vmHandle(const EventLoopTaskNoContext* task); } // namespace Bun diff --git a/src/jsc/bindings/BunWorkerGlobalScope.cpp b/src/jsc/bindings/GlobalEventScope.cpp similarity index 74% rename from src/jsc/bindings/BunWorkerGlobalScope.cpp rename to src/jsc/bindings/GlobalEventScope.cpp index 9f61b71c928f..0ef16f35fb04 100644 --- a/src/jsc/bindings/BunWorkerGlobalScope.cpp +++ b/src/jsc/bindings/GlobalEventScope.cpp @@ -1,16 +1,19 @@ #include "config.h" -#include "BunWorkerGlobalScope.h" +#include "GlobalEventScope.h" +#include "MessagePort.h" +#include "ScriptExecutionContext.h" +#include "ZigGlobalObject.h" #include namespace WebCore { -WTF_MAKE_TZONE_ALLOCATED_IMPL(WorkerGlobalScope); +WTF_MAKE_TZONE_ALLOCATED_IMPL(GlobalEventScope); -void WorkerGlobalScope::onDidChangeListenerImpl(EventTarget& self, const AtomString& eventType, OnDidChangeListenerKind kind) +void GlobalEventScope::onDidChangeListenerImpl(EventTarget& self, const AtomString& eventType, OnDidChangeListenerKind kind) { if (eventType == eventNames().messageEvent) { - auto& global = static_cast(self); + auto& global = static_cast(self); switch (kind) { case Add: if (global.m_messageEventCount == 0) { diff --git a/src/jsc/bindings/BunWorkerGlobalScope.h b/src/jsc/bindings/GlobalEventScope.h similarity index 72% rename from src/jsc/bindings/BunWorkerGlobalScope.h rename to src/jsc/bindings/GlobalEventScope.h index 8375420d508e..c767a442ea69 100644 --- a/src/jsc/bindings/BunWorkerGlobalScope.h +++ b/src/jsc/bindings/GlobalEventScope.h @@ -12,15 +12,15 @@ namespace WebCore { -class WorkerGlobalScope : public RefCounted, public EventTargetWithInlineData { - WTF_MAKE_TZONE_ALLOCATED(WorkerGlobalScope); +class GlobalEventScope : public RefCounted, public EventTargetWithInlineData { + WTF_MAKE_TZONE_ALLOCATED(GlobalEventScope); uint32_t m_messageEventCount { 0 }; static void onDidChangeListenerImpl(EventTarget&, const AtomString&, OnDidChangeListenerKind); public: - WorkerGlobalScope(ScriptExecutionContext* context) + GlobalEventScope(ScriptExecutionContext* context) : EventTargetWithInlineData() , m_context(context) { @@ -30,12 +30,12 @@ class WorkerGlobalScope : public RefCounted, public EventTarg using RefCounted::deref; using RefCounted::ref; - static Ref create(ScriptExecutionContext* context) + static Ref create(ScriptExecutionContext* context) { - return adoptRef(*new WorkerGlobalScope(context)); + return adoptRef(*new GlobalEventScope(context)); } - ~WorkerGlobalScope() = default; + ~GlobalEventScope() = default; EventTargetInterface eventTargetInterface() const final { return EventTargetInterface::DOMWindowEventTargetInterfaceType; } ScriptExecutionContext* scriptExecutionContext() const final { return m_context; } diff --git a/src/jsc/bindings/JSBuffer.cpp b/src/jsc/bindings/JSBuffer.cpp index 826523833ed2..217d29703063 100644 --- a/src/jsc/bindings/JSBuffer.cpp +++ b/src/jsc/bindings/JSBuffer.cpp @@ -406,8 +406,10 @@ JSC::EncodedJSValue JSBuffer__bufferFromPointerAndLengthAndDeinit(JSC::JSGlobalO uint8Array = JSC::JSUint8Array::create(lexicalGlobalObject, subclassStructure, 0); } - // only JSC::JSUint8Array::create can throw and we control the ArrayBuffer passed in. - scope.assertNoException(); + // JSUint8Array::create throws only on OOM — or with a termination request + // pending on this VM (a worker being stopped), which any exception check + // materialises. Either way there is no buffer. + RETURN_IF_EXCEPTION(scope, {}); ASSERT(uint8Array); return JSC::JSValue::encode(uint8Array); diff --git a/src/jsc/bindings/JSCTaskScheduler.cpp b/src/jsc/bindings/JSCTaskScheduler.cpp index 828d54d1461d..4f6b8ceae48f 100644 --- a/src/jsc/bindings/JSCTaskScheduler.cpp +++ b/src/jsc/bindings/JSCTaskScheduler.cpp @@ -1,7 +1,10 @@ #include "config.h" #include +#include +#include #include "JSCTaskScheduler.h" #include "BunClientData.h" +#include "ZigGlobalObject.h" using Ticket = JSC::DeferredWorkTimer::Ticket; using Task = JSC::DeferredWorkTimer::Task; @@ -9,8 +12,7 @@ using Task = JSC::DeferredWorkTimer::Task; namespace Bun { using namespace JSC; -extern "C" void Bun__queueJSCDeferredWorkTaskConcurrently(void* bunVM, void* task); -extern "C" void Bun__eventLoop__incrementRefConcurrently(void* bunVM, int delta); +extern "C" void Bun__queueJSCDeferredWorkTaskConcurrently(const ::BunVmHandleRef*, void* task); class JSCDeferredWorkTask { public: @@ -54,7 +56,7 @@ void JSCTaskScheduler::onAddPendingWork(WebCore::JSVMClientData* clientData, Ref if (scheduler.m_isShuttingDown) [[unlikely]] return; if (kind == DeferredWorkTimer::WorkType::ImminentlyScheduled) { - Bun__eventLoop__incrementRefConcurrently(clientData->bunVM, 1); + Bun__VmHandle__refKeepAlive(clientData->vmHandle, 1); scheduler.m_pendingTicketsKeepingEventLoopAlive.add(WTF::move(ticket)); } else { scheduler.m_pendingTicketsOther.add(WTF::move(ticket)); @@ -63,52 +65,64 @@ void JSCTaskScheduler::onAddPendingWork(WebCore::JSVMClientData* clientData, Ref void JSCTaskScheduler::onScheduleWorkSoon(WebCore::JSVMClientData* clientData, Ref&& ticket, Task&& task) { auto& scheduler = clientData->deferredWorkTimer; - Locker holder { scheduler.m_lock }; - // The event loop is past its last tick; a JSCDeferredWorkTask enqueued now - // would never run and its ConcurrentTask wrapper would leak once the Bun - // VirtualMachine box is dealloc'd. Reached from ~VM -> WaiterListManager:: - // unregister -> Waiter::cancelAndClear for every outstanding - // Atomics.waitAsync on a terminating worker, and from collectNow -> - // JSFinalizationRegistry::finalizeUnconditionally. Balance onAddPendingWork - // so the ticket-set entry and event-loop ref are released. The lock is held - // across the check and the enqueue so the transition in markShuttingDown - // cannot race a cross-thread Atomics.notify. - if (scheduler.m_isShuttingDown) [[unlikely]] { - bool wasKeepingAlive = dropPendingTicketLocked(scheduler, ticket.ptr()); - holder.unlockEarly(); - if (wasKeepingAlive) - Bun__eventLoop__incrementRefConcurrently(clientData->bunVM, -1); - return; + { + Locker holder { scheduler.m_lock }; + // The event loop is past its last tick: don't bother posting. Reached from + // ~VM -> WaiterListManager::unregister -> Waiter::cancelAndClear for every + // outstanding Atomics.waitAsync on a terminating worker, and from + // collectNow -> JSFinalizationRegistry::finalizeUnconditionally. Balance + // onAddPendingWork so the ticket-set entry and event-loop ref are released. + if (scheduler.m_isShuttingDown) [[unlikely]] { + bool wasKeepingAlive = dropPendingTicketLocked(scheduler, ticket.ptr()); + holder.unlockEarly(); + if (wasKeepingAlive) + Bun__VmHandle__refKeepAlive(clientData->vmHandle, -1); + return; + } } + // Outside m_lock (markShuttingDown, on the VM's thread, needs it): a post that + // still races the shutdown lands on the VM handle, which either queues it for + // the teardown to release unrun or refuses it and runs the job's release path. auto* job = new JSCDeferredWorkTask(WTF::move(ticket), WTF::move(task)); - Bun__queueJSCDeferredWorkTaskConcurrently(clientData->bunVM, job); + Bun__queueJSCDeferredWorkTaskConcurrently(clientData->vmHandle, job); } void JSCTaskScheduler::onCancelPendingWork(WebCore::JSVMClientData* clientData, Ticket& ticket) { - auto* bunVM = clientData->bunVM; + auto* vmHandle = clientData->vmHandle; auto& scheduler = clientData->deferredWorkTimer; Locker holder { scheduler.m_lock }; bool wasKeepingAlive = dropPendingTicketLocked(scheduler, &ticket); holder.unlockEarly(); if (wasKeepingAlive) - Bun__eventLoop__incrementRefConcurrently(bunVM, -1); + Bun__VmHandle__refKeepAlive(vmHandle, -1); } -static void runPendingWork(void* bunVM, Bun::JSCTaskScheduler& scheduler, JSCDeferredWorkTask* job) +static void runPendingWork(const ::BunVmHandleRef* vmHandle, Bun::JSCTaskScheduler& scheduler, JSCDeferredWorkTask* job) { Locker holder { scheduler.m_lock }; auto pendingTicket = scheduler.m_pendingTicketsKeepingEventLoopAlive.take(job->ticket); if (!pendingTicket) { pendingTicket = scheduler.m_pendingTicketsOther.take(job->ticket); } else { - Bun__eventLoop__incrementRefConcurrently(bunVM, -1); + Bun__VmHandle__refKeepAlive(vmHandle, -1); } holder.unlockEarly(); - if (pendingTicket && !pendingTicket->isCancelled()) { + // Deferred work runs script (FinalizationRegistry callbacks, wasm + // completions); not once the VM's stop was requested. Like any other + // event-loop callback boundary, an exception a task lets escape is + // reported as uncaught here rather than left on the VM for the next entry. + if (pendingTicket && !pendingTicket->isCancelled() && Bun__VmHandle__scriptAllowed(vmHandle)) { + auto& vm = job->vm(); + auto* globalObject = job->ticket->target()->globalObject(); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); job->task(job->ticket.get()); + if (auto* exception = scope.exception(); exception && !vm.hasPendingTerminationException()) { + scope.clearException(); + Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); + } } delete job; @@ -119,16 +133,7 @@ extern "C" void Bun__runDeferredWork(Bun::JSCDeferredWorkTask* job) auto& vm = job->vm(); auto clientData = WebCore::clientData(vm); - runPendingWork(clientData->bunVM, clientData->deferredWorkTimer, job); -} - -// Flip m_isShuttingDown from the owning JS thread before the final concurrent- -// task drain. Any onScheduleWorkSoon that serializes before this under m_lock -// has its enqueue visible to the drain; any that serializes after drops. -extern "C" void Bun__JSCTaskScheduler__markShuttingDown(JSC::JSGlobalObject* globalObject) -{ - if (auto* clientData = WebCore::clientData(JSC::getVM(globalObject))) - clientData->deferredWorkTimer.markShuttingDown(); + runPendingWork(clientData->vmHandle, clientData->deferredWorkTimer, job); } // Reclaim a queued-but-never-dispatched job during shutdown. Called while the @@ -143,7 +148,7 @@ extern "C" void Bun__deleteDeferredWorkTask(Bun::JSCDeferredWorkTask* job) bool wasKeepingAlive = dropPendingTicketLocked(scheduler, job->ticket.ptr()); holder.unlockEarly(); if (wasKeepingAlive) - Bun__eventLoop__incrementRefConcurrently(clientData->bunVM, -1); + Bun__VmHandle__refKeepAlive(clientData->vmHandle, -1); } delete job; } diff --git a/src/jsc/bindings/JSCTaskScheduler.h b/src/jsc/bindings/JSCTaskScheduler.h index 5d7cf518fec5..345f2daf73b5 100644 --- a/src/jsc/bindings/JSCTaskScheduler.h +++ b/src/jsc/bindings/JSCTaskScheduler.h @@ -21,11 +21,10 @@ class JSCTaskScheduler { static void onCancelPendingWork(WebCore::JSVMClientData* clientData, JSC::DeferredWorkTimer::Ticket& ticket); // Set once the owning VM's event loop has taken its last tick. After this, - // onScheduleWorkSoon drops the task instead of enqueueing a ConcurrentTask - // that can never be drained (~VM -> WaiterListManager::unregister reaches - // it for every still-pending Atomics.waitAsync ticket). Guarded by m_lock - // so the check+enqueue in onScheduleWorkSoon is atomic with respect to this - // transition (a cross-thread Atomics.notify may race a worker's shutdown). + // onScheduleWorkSoon drops the task up front instead of posting it (~VM -> + // WaiterListManager::unregister reaches it for every still-pending + // Atomics.waitAsync ticket). An early-out, not a fence: a post that races + // this is handled by the VM handle (released unrun by the teardown, or refused). void markShuttingDown() { Locker holder { m_lock }; diff --git a/src/jsc/bindings/JSCommonJSModule.cpp b/src/jsc/bindings/JSCommonJSModule.cpp index f849adacd02c..7b78b335e80f 100644 --- a/src/jsc/bindings/JSCommonJSModule.cpp +++ b/src/jsc/bindings/JSCommonJSModule.cpp @@ -1021,7 +1021,7 @@ void populateESMExports( if (!ignoreESModuleAnnotation) { PropertySlot slot(exports, PropertySlot::InternalMethodType::VMInquiry, &vm); auto has = exports->getPropertySlot(globalObject, esModuleMarker, slot); - scope.assertNoException(); + RETURN_IF_EXCEPTION(scope, ); if (has) { JSValue value = slot.getValue(globalObject, esModuleMarker); CLEAR_IF_EXCEPTION(scope); diff --git a/src/jsc/bindings/JSInspectorProfiler.cpp b/src/jsc/bindings/JSInspectorProfiler.cpp index a23358bf56c2..f98e9e8b5799 100644 --- a/src/jsc/bindings/JSInspectorProfiler.cpp +++ b/src/jsc/bindings/JSInspectorProfiler.cpp @@ -59,18 +59,26 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_isCPUProfilerRunning, (JSGlobalObject*, Call // Precise code coverage via JSC's control-flow profiler. Unlike V8 (which // deopts and instruments already-compiled code), only functions compiled from -// this point on are instrumented; recompiling would corrupt live TLA modules. +// this point on are instrumented: JSC's own toggle recompiles everything +// (deleteAllCode when idle), which would corrupt suspended TLA module bodies. +// +// Compiled instrumented code writes through raw BasicBlockLocation pointers the +// profiler owns (op_profile_control_flow), so without that recompile the +// profiler can never be freed again once code has been compiled against it: it +// is enabled once and stays for the VM's lifetime. "stop" is protocol state in +// node/inspector.ts only. JSC_DECLARE_HOST_FUNCTION(jsFunction_startPreciseCoverage); JSC_DEFINE_HOST_FUNCTION(jsFunction_startPreciseCoverage, (JSGlobalObject * globalObject, CallFrame*)) { - globalObject->vm().enableControlFlowProfiler(); + auto& vm = globalObject->vm(); + if (!vm.controlFlowProfiler()) + vm.enableControlFlowProfiler(); return JSValue::encode(jsUndefined()); } JSC_DECLARE_HOST_FUNCTION(jsFunction_stopPreciseCoverage); -JSC_DEFINE_HOST_FUNCTION(jsFunction_stopPreciseCoverage, (JSGlobalObject * globalObject, CallFrame*)) +JSC_DEFINE_HOST_FUNCTION(jsFunction_stopPreciseCoverage, (JSGlobalObject*, CallFrame*)) { - globalObject->vm().disableControlFlowProfiler(); return JSValue::encode(jsUndefined()); } diff --git a/src/jsc/bindings/JSNextTickQueue.cpp b/src/jsc/bindings/JSNextTickQueue.cpp index 71bba18387b8..322811ae8d54 100644 --- a/src/jsc/bindings/JSNextTickQueue.cpp +++ b/src/jsc/bindings/JSNextTickQueue.cpp @@ -74,6 +74,12 @@ bool JSNextTickQueue::isEmpty() return !internalField(0) || internalField(0).get().asNumber() == 0; } +void JSNextTickQueue::discard(JSC::VM& vm) +{ + internalField(0).set(vm, this, jsNumber(0)); + internalField(2).set(vm, this, jsUndefined()); +} + void JSNextTickQueue::drain(JSC::VM& vm, JSC::JSGlobalObject* globalObject) { auto throwScope = DECLARE_THROW_SCOPE(vm); @@ -92,6 +98,8 @@ void JSNextTickQueue::drain(JSC::VM& vm, JSC::JSGlobalObject* globalObject) RETURN_IF_EXCEPTION(throwScope, ); } auto* drainFn = internalField(2).get().getObject(); + if (!drainFn) + return; // discarded at teardown MarkedArgumentBuffer drainArgs; JSC::call(globalObject, drainFn, drainArgs, "Failed to drain next tick queue"_s); RETURN_IF_EXCEPTION(throwScope, ); diff --git a/src/jsc/bindings/JSNextTickQueue.h b/src/jsc/bindings/JSNextTickQueue.h index 0fa25d43bb69..2b84ac9e5ef9 100644 --- a/src/jsc/bindings/JSNextTickQueue.h +++ b/src/jsc/bindings/JSNextTickQueue.h @@ -26,5 +26,8 @@ class JSNextTickQueue : public JSC::JSInternalFieldObjectImpl<3> { bool isEmpty(); void drain(JSC::VM& vm, JSC::JSGlobalObject* globalObject); + // Teardown: whatever was queued no longer runs (field 0 = scheduled flag, field 2 = the JS + // drain function). The queued callbacks go with the heap. + void discard(JSC::VM& vm); }; } diff --git a/src/jsc/bindings/JSNodePerformanceHooksHistogramPrototype.cpp b/src/jsc/bindings/JSNodePerformanceHooksHistogramPrototype.cpp index 7b54af25fc6b..33285be80b4d 100644 --- a/src/jsc/bindings/JSNodePerformanceHooksHistogramPrototype.cpp +++ b/src/jsc/bindings/JSNodePerformanceHooksHistogramPrototype.cpp @@ -149,7 +149,7 @@ static double toPercentile(JSC::ThrowScope& scope, JSGlobalObject* globalObject, // TODO: rewrite validateNumber to return the validated value. double percentile = value.toNumber(globalObject); - scope.assertNoException(); + RETURN_IF_EXCEPTION(scope, {}); if (percentile <= 0 || percentile > 100 || std::isnan(percentile)) { Bun::ERR::OUT_OF_RANGE(scope, globalObject, "percentile"_s, "> 0 && <= 100"_s, value); return {}; diff --git a/src/jsc/bindings/JSSecrets.cpp b/src/jsc/bindings/JSSecrets.cpp index e42f6815e4df..d28dcde973f9 100644 --- a/src/jsc/bindings/JSSecrets.cpp +++ b/src/jsc/bindings/JSSecrets.cpp @@ -223,7 +223,7 @@ struct SecretsJobOptions { } } - scope.assertNoException(); + RETURN_IF_EXCEPTION(scope, nullptr); if (service.isEmpty() || name.isEmpty()) { Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, "Expected service and name to not be empty"_s); diff --git a/src/jsc/bindings/NodeVMModule.cpp b/src/jsc/bindings/NodeVMModule.cpp index 35397a27d5fa..5be76aa6e04e 100644 --- a/src/jsc/bindings/NodeVMModule.cpp +++ b/src/jsc/bindings/NodeVMModule.cpp @@ -1,4 +1,5 @@ #include "NodeVMModule.h" +#include "BunClientData.h" #include "NodeVMSourceTextModule.h" #include "NodeVMSyntheticModule.h" @@ -99,6 +100,12 @@ JSValue NodeVMModule::evaluate(JSGlobalObject* globalObject, uint32_t timeout, b // exception-check validator is satisfied before the TOP scope // below, then convert it to ERR_SCRIPT_EXECUTION_*. std::ignore = scope.exception(); + if ((vm.hasTerminationRequest() || vm.hasPendingTerminationException()) && !Bun__VmHandle__scriptAllowed(WebCore::clientData(vm)->vmHandle)) { + // The VM itself is being stopped; not ours to consume. Propagate the termination. + if (!vm.hasPendingTerminationException()) + vm.throwTerminationException(); + return {}; + } if (vm.hasTerminationRequest() || vm.hasPendingTerminationException()) { vm.drainMicrotasksForGlobalObject(nodeVmGlobalObject); DECLARE_TOP_EXCEPTION_SCOPE(vm).clearException(); @@ -239,6 +246,12 @@ JSValue NodeVMModule::evaluate(JSGlobalObject* globalObject, uint32_t timeout, b // termination one is converted to ERR_SCRIPT_EXECUTION_* here. Observe it // so the exception-check validator is satisfied before the TOP scope. std::ignore = scope.exception(); + if ((vm.hasTerminationRequest() || vm.hasPendingTerminationException()) && !Bun__VmHandle__scriptAllowed(WebCore::clientData(vm)->vmHandle)) { + // The VM itself is being stopped; not ours to consume. Propagate the termination. + if (!vm.hasPendingTerminationException()) + vm.throwTerminationException(); + return {}; + } if (vm.hasTerminationRequest() || vm.hasPendingTerminationException()) { vm.drainMicrotasksForGlobalObject(nodeVmGlobalObject); DECLARE_TOP_EXCEPTION_SCOPE(vm).clearException(); diff --git a/src/jsc/bindings/NodeVMScript.cpp b/src/jsc/bindings/NodeVMScript.cpp index 2fb268cced5c..1a8653a1184a 100644 --- a/src/jsc/bindings/NodeVMScript.cpp +++ b/src/jsc/bindings/NodeVMScript.cpp @@ -1,4 +1,5 @@ #include "NodeVMScript.h" +#include "BunClientData.h" #include "ErrorCode.h" @@ -309,6 +310,11 @@ void NodeVMScript::destroy(JSCell* cell) static bool checkForTermination(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::ThrowScope& scope, NodeVMScript* script, std::optional timeout) { if (vm.hasTerminationRequest()) { + // The whole VM is being stopped (worker terminate()/exit): that + // termination is not ours to consume. The caller rethrows what + // evaluate() caught like any other exception. + if (!Bun__VmHandle__scriptAllowed(WebCore::clientData(vm)->vmHandle)) + return false; vm.drainMicrotasksForGlobalObject(globalObject); // The termination may have fired inside an afterEvaluate microtask // checkpoint, leaving the termination exception pending; clear it so diff --git a/src/jsc/bindings/ScriptExecutionContext.cpp b/src/jsc/bindings/ScriptExecutionContext.cpp index 60f4ca58b9ef..ce74d6344bdb 100644 --- a/src/jsc/bindings/ScriptExecutionContext.cpp +++ b/src/jsc/bindings/ScriptExecutionContext.cpp @@ -1,12 +1,17 @@ #include "root.h" #include "headers.h" #include "ScriptExecutionContext.h" +#include "ActiveDOMObject.h" #include "ContextDestructionObserver.h" #include "libusockets.h" #include "_libusockets.h" #include "BunClientData.h" +#include "GlobalEventScope.h" #include "EventLoopTask.h" +#include "Performance.h" +#include "ZigGlobalObject.h" +#include #include extern "C" void Bun__startLoop(us_loop_t* loop); @@ -34,9 +39,11 @@ static ScriptExecutionContextIdentifier initialIdentifier() DEFINE_ALLOCATOR_WITH_HEAP_IDENTIFIER(ScriptExecutionContext); #endif -ScriptExecutionContext::ScriptExecutionContext(JSC::VM* vm, JSC::JSGlobalObject* globalObject) +ScriptExecutionContext::ScriptExecutionContext(JSC::VM* vm, Zig::GlobalObject* globalObject) : m_vm(vm) , m_globalObject(globalObject) + , m_bunVM(WebCore::clientData(*vm)->bunVM) + , m_vmHandle(WebCore::clientData(*vm)->vmHandle) , m_identifier(initialIdentifier()) , m_contextThreadUID(Thread::currentSingleton().uid()) { @@ -44,9 +51,11 @@ ScriptExecutionContext::ScriptExecutionContext(JSC::VM* vm, JSC::JSGlobalObject* addToContextsMap(); } -ScriptExecutionContext::ScriptExecutionContext(JSC::VM* vm, JSC::JSGlobalObject* globalObject, ScriptExecutionContextIdentifier identifier) +ScriptExecutionContext::ScriptExecutionContext(JSC::VM* vm, Zig::GlobalObject* globalObject, ScriptExecutionContextIdentifier identifier) : m_vm(vm) , m_globalObject(globalObject) + , m_bunVM(WebCore::clientData(*vm)->bunVM) + , m_vmHandle(WebCore::clientData(*vm)->vmHandle) , m_identifier(identifier == std::numeric_limits::max() ? ++lastUniqueIdentifier : identifier) , m_contextThreadUID(Thread::currentSingleton().uid()) { @@ -76,15 +85,24 @@ JSGlobalObject* ScriptExecutionContext::globalObject() return m_globalObject; } -extern "C" void Bun__eventLoop__incrementRefConcurrently(void* bunVM, int delta); +JSGlobalObject* ScriptExecutionContext::jsGlobalObject() +{ + return m_globalObject; +} + +extern "C" void Bun__VM__queueTask(void* bunVM, EventLoopTask*); +extern "C" void Bun__VM__queueTaskAfterYield(void* bunVM, EventLoopTask*); +extern "C" void Bun__VmHandle__queueTaskConcurrently(const ::BunVmHandleRef*, EventLoopTask*); +// JS thread (this context's thread): MessagePort / BroadcastChannel / worker global scope +// keep-alives. Direct, so a ref taken before teardown is still released during it. void ScriptExecutionContext::refEventLoop() { - Bun__eventLoop__incrementRefConcurrently(WebCore::clientData(vm())->bunVM, 1); + Bun__eventLoop__refKeepAlive(m_bunVM, 1); } void ScriptExecutionContext::unrefEventLoop() { - Bun__eventLoop__incrementRefConcurrently(WebCore::clientData(vm())->bunVM, -1); + Bun__eventLoop__refKeepAlive(m_bunVM, -1); } ScriptExecutionContext::~ScriptExecutionContext() @@ -107,49 +125,76 @@ ScriptExecutionContext::~ScriptExecutionContext() #endif // ASSERT_ENABLED } -bool ScriptExecutionContext::postTaskTo(ScriptExecutionContextIdentifier identifier, Function&& task) +void ScriptExecutionContext::forEachActiveDOMObject(NOESCAPE const Function& apply) const { - Locker locker { allScriptExecutionContextsMapLock }; - auto* context = allScriptExecutionContextsMap().get(identifier); + // It is not allowed to run arbitrary script or construct new ActiveDOMObjects while we are iterating over ActiveDOMObjects. + // A RELEASE_ASSERT will fire if this happens, but it's important to code + // stop() functions so it will not happen! + SetForScope activeDOMObjectAdditionForbiddenScope(m_activeDOMObjectAdditionForbidden, true); - if (!context) - return false; + // Make a frozen copy of the objects so we can iterate while new ones might be destroyed. + auto possibleActiveDOMObjects = copyToVectorOf>(m_activeDOMObjects); + for (auto& weakActiveDOMObject : possibleActiveDOMObjects) { + RefPtr activeDOMObject = weakActiveDOMObject.get(); + if (activeDOMObject && apply(*activeDOMObject) == ShouldContinue::No) + break; + } +} - // A permanently-terminating context never drains its concurrent queue, so a task - // enqueued during teardown would leak its captured refs (e.g. notifyPeerClosed - // pinning the MessagePortPipe) — drop it. Gate on the worker-teardown flag, not - // VM::hasTerminationRequest(), which node:vm {timeout}/{breakOnSigint} sets transiently. - if (context->isTerminating()) - return false; +void ScriptExecutionContext::stopActiveDOMObjects() +{ + checkConsistency(); - context->postTaskConcurrently(WTF::move(task)); - return true; + if (m_activeDOMObjectsAreStopped) + return; + m_activeDOMObjectsAreStopped = true; + + forEachActiveDOMObject([](auto& activeDOMObject) { + activeDOMObject.stop(); + return ShouldContinue::Yes; + }); } -// Like the overload above (including the isTerminating() gate — a grandchild's -// dispatchExit can observe its parent context between markTerminating() and -// removeFromContextsMap()), except `betweenLookupAndEnqueue()` runs after the -// target context is found-live but before the task is enqueued (i.e. before -// the target thread can observe / run / destroy it). The map lock is held -// across the callback. Used by `Worker::dispatchExit` so the worker thread can -// release its create-time ref while the lambda's captured `Ref` is still owned -// by the worker-thread stack — once enqueued, the parent could run and destroy -// it before the calling frame resumes, making any later `deref()` on the worker -// thread potentially the last (~Worker on the wrong thread, EventListenerMap -// thread-UID assert). -bool ScriptExecutionContext::postTaskTo(ScriptExecutionContextIdentifier identifier, NOESCAPE const WTF::Function& betweenLookupAndEnqueue, Function&& task) +void ScriptExecutionContext::suspendActiveDOMObjectIfNeeded(ActiveDOMObject& activeDOMObject) { - Locker locker { allScriptExecutionContextsMapLock }; - auto* context = allScriptExecutionContextsMap().get(identifier); + ASSERT(m_activeDOMObjects.contains(activeDOMObject)); + if (m_activeDOMObjectsAreStopped) + activeDOMObject.stop(); +} - if (!context) - return false; +void ScriptExecutionContext::didCreateActiveDOMObject(ActiveDOMObject& activeDOMObject) +{ + // The m_activeDOMObjectAdditionForbidden check is a RELEASE_ASSERT because of the + // consequences of having an ActiveDOMObject that is not correctly reflected in the set. + // If we do have one of those, it can possibly be a security vulnerability. So we'd + // rather have a crash than continue running with the set possibly compromised. + ASSERT(!m_inScriptExecutionContextDestructor); + RELEASE_ASSERT(!m_activeDOMObjectAdditionForbidden); + m_activeDOMObjects.add(activeDOMObject); +} - if (context->isTerminating()) - return false; +void ScriptExecutionContext::willDestroyActiveDOMObject(ActiveDOMObject& activeDOMObject) +{ + m_activeDOMObjects.remove(activeDOMObject); +} - betweenLookupAndEnqueue(); - context->postTaskConcurrently(WTF::move(task)); +bool ScriptExecutionContext::postTaskTo(ScriptExecutionContextIdentifier identifier, Function&& task) +{ + // The map lock covers the lookup only. The context may be destroyed the moment the + // lock is released, so nothing of it is used afterwards except a count taken on its + // VM handle, and the post goes through that: queued while the VM accepts posts, + // deleted unrun once it does not (and anything queued during its teardown is + // released unrun by that teardown). Posting inside the critical section would make + // every other context's lookup wait on this VM's queue. + const BunVmHandleRef* retained = nullptr; + { + Locker locker { allScriptExecutionContextsMapLock }; + auto* context = allScriptExecutionContextsMap().get(identifier); + if (!context || context->isTerminating()) + return false; + retained = Bun__VmHandle__retainRef(context->m_vmHandle); + } + Bun__VmHandle__postAndRelease(retained, new EventLoopTask(WTF::move(task))); return true; } @@ -163,16 +208,45 @@ void ScriptExecutionContext::didCreateDestructionObserver(ContextDestructionObse void ScriptExecutionContext::willDestroyDestructionObserver(ContextDestructionObserver& observer) { - // This can legitimately run during context teardown: a ContextDestructionObserver - // (e.g. a MessagePort kept alive by a pending message-dispatch task) may have its - // last ref released from within ~ScriptExecutionContext. remove() is safe during - // teardown (the set is drained one element at a time, not iterated concurrently). m_destructionObservers.remove(&observer); } bool ScriptExecutionContext::isJSExecutionForbidden() { - return !m_vm || m_vm->executionForbidden(); + return !m_vm || m_vm->executionForbidden() || !WebCore::clientData(*m_vm)->scriptAllowed(); +} + +void ScriptExecutionContext::prepareForDestruction() +{ + ASSERT(isContextThread()); + ASSERT(m_globalObject); + + stopActiveDOMObjects(); + + // Event listeners would keep DOMWrapperWorld objects alive for too long. Also, they have references to JS objects, + // which become dangling once Heap is destroyed. + removeAllEventListeners(); +} + +void ScriptExecutionContext::removeAllEventListeners() +{ + m_globalObject->globalEventScope->removeAllEventListeners(); + if (RefPtr performance = m_globalObject->existingPerformance()) { + performance->removeAllEventListeners(); + performance->removeAllObservers(); + } +} + +void ScriptExecutionContext::globalObjectDestroyed() +{ + ASSERT(isContextThread()); + // A global collected on a live VM (ShadowRealm, a retired `bun test --isolate` global) never + // went through prepareForDestruction(); its context-owned targets still hold listeners and the + // Performance <-> PerformanceObserver cycle. + removeAllEventListeners(); + removeFromContextsMap(); + m_globalObject = nullptr; + m_vm = nullptr; } bool ScriptExecutionContext::isContextThread() @@ -183,19 +257,21 @@ bool ScriptExecutionContext::isContextThread() bool ScriptExecutionContext::ensureOnContextThread(ScriptExecutionContextIdentifier identifier, Function&& task) { ScriptExecutionContext* context = nullptr; + const BunVmHandleRef* retained = nullptr; { Locker locker { allScriptExecutionContextsMapLock }; context = allScriptExecutionContextsMap().get(identifier); - if (!context) return false; - - if (!context->isContextThread()) { - context->postTaskConcurrently(WTF::move(task)); - return true; - } + if (!context->isContextThread()) + retained = Bun__VmHandle__retainRef(context->m_vmHandle); } - + if (retained) { + // Off its thread: as postTaskTo(), through the handle, outside the lock. + Bun__VmHandle__postAndRelease(retained, new EventLoopTask(WTF::move(task))); + return true; + } + // On its own thread the context cannot be destroyed under us. task(*context); return true; } @@ -223,6 +299,12 @@ void ScriptExecutionContext::checkConsistency() const #if ASSERT_ENABLED for (auto* destructionObserver : m_destructionObservers) ASSERT(destructionObserver->scriptExecutionContext() == this); + + // This can run on a GC thread. + for (SUPPRESS_UNCOUNTED_LOCAL auto& activeDOMObject : m_activeDOMObjects) { + ASSERT(activeDOMObject.scriptExecutionContext() == this); + activeDOMObject.assertSuspendIfNeededWasCalled(); + } #endif // ASSERT_ENABLED } @@ -255,13 +337,10 @@ void ScriptExecutionContext::removeFromContextsMap() void ScriptExecutionContext::markTerminating() { - // postTaskTo() holds this lock across its isTerminating() check and - // postTaskConcurrently() enqueue. Taking it here establishes an ordering - // with every concurrent poster: either its whole critical section ran - // before ours (task enqueued, and the caller's subsequent concurrent-queue - // drain will see it), or ours ran first (poster observes true and drops - // the task instead of enqueueing onto a queue that will never drain). - Locker locker { allScriptExecutionContextsMapLock }; + // An early-out for postTaskTo(): from here posts to this context are pointless. Not + // a fence — a poster that looked us up just before this still posts, and the VM + // handle deals with it (queued and released unrun by the teardown, or refused and + // deleted once the handle is closed). m_isTerminating.store(true, std::memory_order_release); } @@ -274,19 +353,22 @@ ScriptExecutionContext* executionContext(JSC::JSGlobalObject* globalObject) void ScriptExecutionContext::postTaskConcurrently(Function&& lambda) { - auto* task = new EventLoopTask(WTF::move(lambda)); - static_cast(m_globalObject)->queueTaskConcurrently(task); + Bun__VmHandle__queueTaskConcurrently(m_vmHandle, new EventLoopTask(WTF::move(lambda))); } // Executes the task on context's thread asynchronously. void ScriptExecutionContext::postTask(Function&& lambda) { - auto* task = new EventLoopTask(WTF::move(lambda)); - static_cast(m_globalObject)->queueTask(task); + Bun__VM__queueTask(m_bunVM, new EventLoopTask(WTF::move(lambda))); } // Executes the task on context's thread asynchronously. void ScriptExecutionContext::postTask(EventLoopTask* task) { - static_cast(m_globalObject)->queueTask(task); + Bun__VM__queueTask(m_bunVM, task); +} +// Same thread; runs on the next loop iteration, after I/O and timers have had a turn. +void ScriptExecutionContext::postTaskAfterYield(Function&& lambda) +{ + Bun__VM__queueTaskAfterYield(m_bunVM, new EventLoopTask(WTF::move(lambda))); } // Native bindings @@ -302,10 +384,4 @@ extern "C" JSC::JSGlobalObject* ScriptExecutionContextIdentifier__getGlobalObjec return context->globalObject(); } -extern "C" void ScriptExecutionContext__markTerminating(JSC::JSGlobalObject* globalObject) -{ - if (auto* context = defaultGlobalObject(globalObject)->scriptExecutionContext()) - context->markTerminating(); -} - } // namespace WebCore diff --git a/src/jsc/bindings/ScriptExecutionContext.h b/src/jsc/bindings/ScriptExecutionContext.h index 4e14e13807bb..25e3e4fb25dc 100644 --- a/src/jsc/bindings/ScriptExecutionContext.h +++ b/src/jsc/bindings/ScriptExecutionContext.h @@ -1,11 +1,14 @@ #pragma once #include "root.h" + +struct BunVmHandleRef; #include "SharedEnvStore.h" #include #include #include #include +#include #include #include #include @@ -22,6 +25,10 @@ struct us_socket_t; struct us_socket_group_t; struct us_loop_t; +namespace Zig { +class GlobalObject; +} + namespace WebCore { class WebSocket; @@ -29,6 +36,7 @@ class WebSocket; class ScriptExecutionContext; class EventLoopTask; +class ActiveDOMObject; class ContextDestructionObserver; using ScriptExecutionContextIdentifier = uint32_t; @@ -44,17 +52,14 @@ class ScriptExecutionContext : public CanMakeWeakPtr, pu #endif public: - ScriptExecutionContext(JSC::VM* vm, JSC::JSGlobalObject* globalObject); - ScriptExecutionContext(JSC::VM* vm, JSC::JSGlobalObject* globalObject, ScriptExecutionContextIdentifier identifier); + ScriptExecutionContext(JSC::VM* vm, Zig::GlobalObject* globalObject); + ScriptExecutionContext(JSC::VM* vm, Zig::GlobalObject* globalObject, ScriptExecutionContextIdentifier identifier); ~ScriptExecutionContext(); static ScriptExecutionContextIdentifier generateIdentifier(); - JSC::JSGlobalObject* jsGlobalObject() - { - return m_globalObject; - } + JSC::JSGlobalObject* jsGlobalObject(); static ScriptExecutionContext* getScriptExecutionContext(ScriptExecutionContextIdentifier identifier); void refEventLoop(); @@ -67,9 +72,32 @@ class ScriptExecutionContext : public CanMakeWeakPtr, pu return m_url; } bool isMainThread() const { return m_identifier == 1; } - bool activeDOMObjectsAreSuspended() { return false; } - bool activeDOMObjectsAreStopped() { return false; } bool isContextThread(); + + // Active objects are not garbage collected even if inaccessible, e.g. because their activity may result in callbacks being invoked. + void stopActiveDOMObjects(); + // Also read on the GC thread (isContextStopped() from isReachableFromOpaqueRoots). + bool activeDOMObjectsAreStopped() const { return m_activeDOMObjectsAreStopped.load(std::memory_order_relaxed); } + + // Called from the constructor and destructors of ActiveDOMObject. + void didCreateActiveDOMObject(ActiveDOMObject&); + void willDestroyActiveDOMObject(ActiveDOMObject&); + + // Called once after an ActiveDOMObject is constructed: stops it if this context already stopped. + void suspendActiveDOMObjectIfNeeded(ActiveDOMObject&); + + enum class ShouldContinue : bool { No, + Yes }; + void forEachActiveDOMObject(NOESCAPE const Function&) const; + + // WorkerOrWorkletGlobalScope::prepareForDestruction(): the one point, while script may still + // run, where every ActiveDOMObject is stopped and every listener on context-owned targets is + // removed. Runs before VM teardown, and when a live VM retires this context's global. + void prepareForDestruction(); + void removeAllEventListeners(); + // The owning Zig::GlobalObject cell is being destroyed; from here on there is no global/VM. + void globalObjectDestroyed(); + bool isDocument() { return false; } bool isWorkerGlobalScope() { return true; } bool isJSExecutionForbidden(); @@ -77,7 +105,6 @@ class ScriptExecutionContext : public CanMakeWeakPtr, pu { } WEBCORE_EXPORT static bool postTaskTo(ScriptExecutionContextIdentifier identifier, Function&& task); - WEBCORE_EXPORT static bool postTaskTo(ScriptExecutionContextIdentifier identifier, NOESCAPE const WTF::Function& betweenLookupAndEnqueue, Function&& task); WEBCORE_EXPORT static bool ensureOnContextThread(ScriptExecutionContextIdentifier, Function&& task); WEBCORE_EXPORT static bool ensureOnMainThread(Function&& task); @@ -97,6 +124,7 @@ class ScriptExecutionContext : public CanMakeWeakPtr, pu void postTask(Function&& lambda); // Executes the task on context's thread asynchronously. void postTask(EventLoopTask* task); + void postTaskAfterYield(Function&& lambda); template void postCrossThreadTask(Arguments&&... arguments) @@ -124,26 +152,30 @@ class ScriptExecutionContext : public CanMakeWeakPtr, pu Bun::SharedEnvStore* sharedEnvStore() const { return m_sharedEnvStore.get(); } void setSharedEnvStore(Bun::SharedEnvStore& store) { m_sharedEnvStore = &store; } - void setGlobalObject(JSC::JSGlobalObject* globalObject) - { - m_globalObject = globalObject; - m_vm = &globalObject->vm(); - } - static ScriptExecutionContext* getMainThreadScriptExecutionContext(); private: std::atomic m_isTerminating { false }; RefPtr m_sharedEnvStore; JSC::VM* m_vm = nullptr; - JSC::JSGlobalObject* m_globalObject = nullptr; + Zig::GlobalObject* m_globalObject = nullptr; + // The thread's Bun VM; outlives every global created on it and, during teardown, the JSC::VM. + void* const m_bunVM; + // What other threads use to reach the VM (see JSVMClientData::vmHandle). + const ::BunVmHandleRef* const m_vmHandle; WTF::URL m_url = WTF::URL(); ScriptExecutionContextIdentifier m_identifier; // Snapshot of the creating thread's UID; used by isContextThread() so the // check stays valid after VM clientData / VMHolder are torn down on exit. uint32_t m_contextThreadUID; - UncheckedKeyHashSet m_destructionObservers; + WeakHashSet m_activeDOMObjects; + // Registered in the observer's constructor, removed in its destructor, both + // on this context's thread: plain pointers, nothing allocated per observer. + HashSet m_destructionObservers; + + std::atomic m_activeDOMObjectsAreStopped { false }; + mutable bool m_activeDOMObjectAdditionForbidden { false }; public: #if ASSERT_ENABLED diff --git a/src/jsc/bindings/ServerRouteList.cpp b/src/jsc/bindings/ServerRouteList.cpp index 9150d93f96a7..7793c0e277aa 100644 --- a/src/jsc/bindings/ServerRouteList.cpp +++ b/src/jsc/bindings/ServerRouteList.cpp @@ -250,7 +250,7 @@ JSValue ServerRouteList::callRoute(Zig::GlobalObject* globalObject, uint32_t ind auto* params = paramsObjectForRoute(vm, globalObject, index, req); JSBunRequest* request = JSBunRequest::create(vm, structure, requestPtr, params); - scope.assertNoException(); + RETURN_IF_EXCEPTION(scope, {}); *requestObject = JSValue::encode(request); JSValue callback = m_routes.at(index).get(); diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 04724225b631..3022c09cafe3 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -1,6 +1,7 @@ #include "root.h" #include "ZigGlobalObject.h" +#include "MessagePort.h" #include "helpers.h" #include "JavaScriptCore/ArgList.h" #include "JavaScriptCore/JSCellButterfly.h" @@ -70,7 +71,7 @@ #include "BunSecureContextCache.h" #include "NodeV8.h" #include "ProcessIdentifier.h" -#include "BunWorkerGlobalScope.h" +#include "GlobalEventScope.h" #include "CallSite.h" #include "CallSitePrototype.h" #include "FormatStackTraceForJS.h" @@ -514,7 +515,7 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client, // Every JS VM's RunLoop should use Bun's RunLoop implementation ASSERT(vmPtr->runLoop().kind() == WTF::RunLoop::Kind::Bun); - WebCore::JSVMClientData::create(&vm, Bun__getVM()); + WebCore::JSVMClientData::create(&vm, Bun__getVM(), /* isWorkerVM */ worker_ptr != nullptr); const auto createGlobalObject = [&]() -> Zig::GlobalObject* { if (executionContextId == std::numeric_limits::max() || executionContextId > 1) [[unlikely]] { @@ -575,7 +576,7 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client, }); if (executionContextId > -1) { - const auto initializeWorker = [&](WebCore::Worker& worker) -> void { + const auto initializeWorker = [&](WebCore::WorkerMessagingProxy& worker) -> void { auto& options = worker.options(); if (options.env.has_value()) { @@ -603,9 +604,11 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client, size_t i = 0; for (auto k : map) { // Numeric env keys hit putDirectIndex → defineOwnProperty (declares a - // ThrowScope). Seeded values are JSStrings so only OOM can throw. + // ThrowScope). Seeded values are JSStrings, so this throws only on OOM + // or under a termination already requested for this starting worker. env->putDirectMayBeIndex(globalObject, JSC::Identifier::fromString(vm, WTF::move(k.key)), strings.at(i++)); - scope.assertNoException(); + if (scope.exception()) [[unlikely]] + break; } globalObject->m_processEnvObject.set(vm, globalObject, env); } else if (options.sharedEnvStore) { @@ -621,12 +624,14 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client, // that we can request their termination from another thread. For the main thread, we // can delay this until we are actually requesting termination (until and unless we ever // do need to request termination from another thread). + // + // Execution is forbidden by the exit path (GlobalObject::forbidExecution), not by any + // TerminationException: node:vm {timeout} and breakOnSigint terminate transiently and the + // worker keeps running afterwards. vm.ensureTerminationException(); - // Make the VM stop sooner once terminated (e.g. microtasks won't run) - vm.forbidExecutionOnTermination(); }; - if (auto* worker = static_cast(worker_ptr)) { + if (auto* worker = static_cast(worker_ptr)) { initializeWorker(*worker); } } @@ -654,10 +659,14 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__createForTestIsolation(Zig::G // gcProtect()'d and the old one is cleanly unprotected. JSC::DeferGC deferGC(vm); + // The old global's workers, ports, channels and sockets were stopped by the runtime + // (Zig__GlobalObject__stopActiveDOMObjectsForTestIsolation) before its sweeps and before this. + auto* oldContext = oldGlobal->scriptExecutionContext(); + ASSERT(oldContext->activeDOMObjectsAreStopped()); + // The new global must inherit the old one's ScriptExecutionContext identifier so that // `Bun.isMainThread` (identifier == 1) and cross-thread task dispatch keep working. // Move the old context to a fresh identifier first to free the slot. - auto* oldContext = oldGlobal->scriptExecutionContext(); const auto inheritedId = oldContext->identifier(); oldContext->removeFromContextsMap(); oldContext->regenerateIdentifier(); @@ -1036,7 +1045,7 @@ GlobalObject::GlobalObject(JSC::VM& vm, JSC::Structure* structure, const JSC::Gl , m_worldIsNormal(true) , m_builtinInternalFunctions(makeUnique(vm)) , m_scriptExecutionContext(new WebCore::ScriptExecutionContext(&vm, this)) - , globalEventScope(adoptRef(*new Bun::WorkerGlobalScope(m_scriptExecutionContext))) + , globalEventScope(adoptRef(*new Bun::GlobalEventScope(m_scriptExecutionContext))) { // m_scriptExecutionContext = globalEventScope.m_context; mockModule = Bun::JSMockModule::create(this); @@ -1051,7 +1060,7 @@ GlobalObject::GlobalObject(JSC::VM& vm, JSC::Structure* structure, WebCore::Scri , m_worldIsNormal(true) , m_builtinInternalFunctions(makeUnique(vm)) , m_scriptExecutionContext(new WebCore::ScriptExecutionContext(&vm, this, contextId)) - , globalEventScope(adoptRef(*new Bun::WorkerGlobalScope(m_scriptExecutionContext))) + , globalEventScope(adoptRef(*new Bun::GlobalEventScope(m_scriptExecutionContext))) { // m_scriptExecutionContext = globalEventScope.m_context; mockModule = Bun::JSMockModule::create(this); @@ -1060,22 +1069,8 @@ GlobalObject::GlobalObject(JSC::VM& vm, JSC::Structure* structure, WebCore::Scri GlobalObject::~GlobalObject() { - // Break the Performance <-> PerformanceObserver reference cycle before the - // ScriptExecutionContext is torn down. Performance holds RefPtr - // in its registered-observer list and each PerformanceObserver holds RefPtr, - // so neither is released unless the cycle is explicitly broken. WebKit does this from - // WorkerGlobalScope / LocalDOMWindow on removeAllEventListeners(); Bun has no equivalent - // hook, so this is the last point where the context is still fully alive. Doing it in - // Performance::contextDestroyed() instead is too late: dropping the last observer ref - // there cascades into ~ContextDestructionObserver() unregistering from the context while - // the context is already iterating observers in its own destructor. - if (m_performance) - m_performance->removeAllObservers(); - - if (auto* ctx = scriptExecutionContext()) { - ctx->removeFromContextsMap(); - ctx->deref(); - } + m_scriptExecutionContext->globalObjectDestroyed(); + m_scriptExecutionContext->deref(); } void GlobalObject::destroy(JSCell* cell) @@ -1777,9 +1772,9 @@ JSC_DEFINE_HOST_FUNCTION(makeGetterTypeErrorForBuiltins, (JSGlobalObject * globa auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); auto interfaceName = callFrame->uncheckedArgument(0).getString(globalObject); - scope.assertNoException(); + RETURN_IF_EXCEPTION(scope, {}); auto attributeName = callFrame->uncheckedArgument(1).getString(globalObject); - scope.assertNoException(); + RETURN_IF_EXCEPTION(scope, {}); auto error = static_cast(createTypeError(globalObject, JSC::makeDOMAttributeGetterTypeErrorMessage(interfaceName.utf8().data(), attributeName))); error->setNativeGetterTypeError(); @@ -1796,10 +1791,10 @@ JSC_DEFINE_HOST_FUNCTION(makeDOMExceptionForBuiltins, (JSGlobalObject * globalOb auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); auto codeValue = callFrame->uncheckedArgument(0).getString(globalObject); - scope.assertNoException(); + RETURN_IF_EXCEPTION(scope, {}); auto message = callFrame->uncheckedArgument(1).getString(globalObject); - scope.assertNoException(); + RETURN_IF_EXCEPTION(scope, {}); ExceptionCode code { TypeError }; if (codeValue == "AbortError"_s) @@ -3439,8 +3434,6 @@ extern "C" void JSGlobalObject__clearTerminationException(JSC::JSGlobalObject* g } } -extern "C" void Bun__queueTask(JSC::JSGlobalObject*, WebCore::EventLoopTask* task); -extern "C" void Bun__queueTaskConcurrently(JSC::JSGlobalObject*, WebCore::EventLoopTask* task); extern "C" [[ZIG_EXPORT(check_slow)]] void Bun__performTask(Zig::GlobalObject* globalObject, WebCore::EventLoopTask* task) { task->performTask(*globalObject->scriptExecutionContext()); @@ -3466,16 +3459,6 @@ RefPtr GlobalObject::performance() return m_performance; } -void GlobalObject::queueTask(WebCore::EventLoopTask* task) -{ - Bun__queueTask(this, task); -} - -void GlobalObject::queueTaskConcurrently(WebCore::EventLoopTask* task) -{ - Bun__queueTaskConcurrently(this, task); -} - extern "C" void Bun__handleRejectedPromise(Zig::GlobalObject* JSGlobalObject, JSC::JSPromise* promise); void GlobalObject::handleRejectedPromises() @@ -3926,11 +3909,37 @@ JSC::JSObject* GlobalObject::moduleLoaderCreateImportMetaProperties(JSGlobalObje return Zig::ImportMetaObject::create(globalObject, key); } +extern "C" bool Bun__VM__entryEvaluationStarted(void*); +extern "C" void Bun__VM__entryRootKey(void*, BunString*); +extern "C" void Bun__VM__noteEntryEvaluationStarted(void*); + +// A module body is about to run. That means "the entry's graph is linked and executing" only if it is +// part of the entry root's own evaluation — the root's record is Evaluating (or beyond) from the moment +// linkAndEvaluateModule() enters it, and its dependencies run inside that (post-order). A module that +// evaluates before then is some other root: a preload's un-awaited import() finishing while the entry is +// still fetching. +static void noteModuleEvaluation(Zig::GlobalObject* globalObject, JSModuleLoader* moduleLoader) +{ + void* bunVM = globalObject->bunVM(); + if (Bun__VM__entryEvaluationStarted(bunVM)) + return; + BunString rootKey; + Bun__VM__entryRootKey(bunVM, &rootKey); + auto* entry = moduleLoader->registryEntry(JSC::Identifier::fromString(globalObject->vm(), rootKey.toWTFString(BunString::ZeroCopy))); + if (!entry) + return; + auto* cyclic = dynamicDowncast(entry->record()); + if (!cyclic || cyclic->status() < JSC::CyclicModuleRecord::Status::Evaluating) + return; + Bun__VM__noteEntryEvaluationStarted(bunVM); +} + JSC::JSValue GlobalObject::moduleLoaderEvaluate(JSGlobalObject* lexicalGlobalObject, JSModuleLoader* moduleLoader, JSValue key, JSValue moduleRecordValue, RefPtr scriptFetcher, JSValue sentValue, JSValue resumeMode) { + noteModuleEvaluation(defaultGlobalObject(lexicalGlobalObject), moduleLoader); return moduleLoader->evaluateNonVirtual(lexicalGlobalObject, key, moduleRecordValue, WTF::move(scriptFetcher), sentValue, resumeMode); } @@ -3947,6 +3956,7 @@ JSC::JSValue EvalGlobalObject::moduleLoaderEvaluate(JSGlobalObject* lexicalGloba auto& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); + noteModuleEvaluation(globalObject, moduleLoader); JSC::JSValue result = moduleLoader->evaluateNonVirtual(lexicalGlobalObject, key, moduleRecordValue, WTF::move(scriptFetcher), sentValue, resumeMode); // The new C++ loader propagates the module body's throw out of @@ -4218,55 +4228,158 @@ void GlobalObject::setNodeWorkerEntryEvaluatedHook(JSObject* hook) extern "C" void Bun__InspectorConnection__disconnectAllOnExit(Zig::GlobalObject*); -extern "C" void Zig__GlobalObject__destructOnExit(Zig::GlobalObject* globalObject) +void GlobalObject::setNodeParentPort(WebCore::MessagePort* port) { - auto& vm = JSC::getVM(globalObject); - if (vm.entryScope) { - vm.entryScope = nullptr; + m_nodeParentPort = port; +} + +void GlobalObject::nodeWorkerEntryDidSettle() +{ + m_nodeWorkerEntrySettled = true; + if (m_nodeParentPort) + m_nodeParentPort->entrySettled(); +} + +void GlobalObject::prepareForDestruction() +{ + auto& vm = this->vm(); + auto* context = m_scriptExecutionContext; + + // Whatever was queued before exit began does not resurrect during teardown: process.exit() + // runs 'exit' handlers and nothing after them (Node), and a worker's stop phase dispatches + // close events, not stale microtasks. Anything the stop phase itself queues drains with it. + vm.defaultMicrotaskQueue().clear(); + if (auto* nextTickQueue = m_nextTickQueue.get()) + nextTickQueue->discard(vm); + + // Tell cross-thread posters not to bother from here (what still lands is queued and released + // unrun by the teardown, or refused once the VM handle closes). DeferredWorkTimer is fenced + // separately because finalizers during the final collection and ~VM both reach scheduleWorkSoon(). + context->markTerminating(); + WebCore::clientData(vm)->deferredWorkTimer.markShuttingDown(); + + // WorkerOrWorkletGlobalScope::prepareForDestruction(): stop every ActiveDOMObject (workers are + // asked to terminate, ports/channels/sockets close without dispatching) and strip listeners, + // while script can still run. + context->prepareForDestruction(); +} + +void GlobalObject::clearDOMGuardedObjects() +{ + // No lock: clear() takes the GC lock itself when it removes the entry (JSDOMGlobalObject). + auto guardedObjectsCopy = m_guardedObjects; + for (auto& guarded : guardedObjectsCopy) + guarded->clear(); +} + +void GlobalObject::forbidExecution() +{ + auto& vm = this->vm(); + + // MicrotaskQueue references Heap. + vm.defaultMicrotaskQueue().clear(); + + // Drop the module registry and require() cache so module-level bindings become unreachable + // for the final collection (their ExternalStringImpl deallocators must run before ~VM). + { + auto* moduleLoader = this->moduleLoader(); + // JSModuleLoader::visitChildrenImpl iterates these maps on the GC thread under cellLock(). + WTF::Locker locker { moduleLoader->cellLock() }; + moduleLoader->clearAll(); } - // Mirror WebWorker__teardownJSCVM: mark this context terminating so late - // worker→parent posts (scheduleDrain/notifyPeerClosed) return false instead - // of enqueueing a ConcurrentTask that leaks past the last drain. - if (auto* ctx = globalObject->scriptExecutionContext()) - ctx->markTerminating(); - if (auto* clientData = WebCore::clientData(vm)) - clientData->deferredWorkTimer.markShuttingDown(); - Bun__InspectorConnection__disconnectAllOnExit(globalObject); - // Hold a Ref so the RunLoop is guaranteed to outlive the VM teardown below. - Ref runLoop = vm.runLoop(); { - // Drop the module loader's registry and the require() cache before - // collecting, so module-level bindings become unreachable. Without - // this, every value stored in a module top-level binding (e.g. the - // `tmpdirs[]` array in test/harness.ts that keeps mkdtempSync paths) - // is rooted through the registry and survives collectNow(), so the - // ExternalStringImpl deallocators never run and LSan reports the - // backing buffers as leaked. Mirrors WebWorker__teardownJSCVM. - auto scope = DECLARE_THROW_SCOPE(vm); - { - auto* moduleLoader = globalObject->moduleLoader(); - WTF::Locker locker { moduleLoader->cellLock() }; - moduleLoader->clearAll(); - } - globalObject->requireMap()->clear(globalObject); - scope.exception(); // mirror WebWorker__teardownJSCVM — leave any pending exception in place + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + requireMap()->clear(this); + scope.clearException(); } - gcUnprotect(globalObject); - globalObject = nullptr; + + // WorkerOrWorkletScriptController::forbidExecution() + scheduleExecutionTermination(): no script + // past this point. executionForbidden is what the native→JS boundary (Bun__JSValue__call, + // JSEventListener via isJSExecutionForbidden) and JSC's microtask drain consult; the + // termination request unwinds anything JSC enters internally, which needs the exception + // object to exist (a main-thread VM never materialized it before this). + vm.ensureTerminationException(); + vm.setExecutionForbidden(); + vm.setHasTerminationRequest(); +} + +extern "C" void Bun__GlobalObject__clearExceptionsForExit(Zig::GlobalObject* globalObject) +{ + // Whatever unwound script to reach the exit sequence — the stop trap (a termination + // request/exception) or an ordinary exception thrown across process.exit() — is spent; + // the native teardown that follows must not trip over it (Node's EmitProcessExit runs + // under a TryCatch for the same reason). + auto& vm = globalObject->vm(); + vm.clearHasTerminationRequest(); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + if (scope.exception()) + scope.clearException(); +} + +static void destroyVM(JSC::VM& vm) +{ vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); - // The two refs that exist when this runs at event-loop top level are - // Zig__GlobalObject__create's manual ref and the boot-scope JSLockHolder. - // When process.exit() is called from inside a JS callback, every nested - // JSLockHolder still on the native stack (e.g. JSEventListener::handleEvent) - // holds a RefPtr, so a fixed two derefs leave the count > 0 and ~VM - // (and with it Heap::lastChanceToFinalize, which clears all marks and - // sweeps every cell) is skipped. Those holders never destruct because this - // path never returns, so release on their behalf. + // Every JSLockHolder still on the native stack (process.exit() from inside a JS callback, + // the worker thread's manual API lock) holds a RefPtr that will never destruct because + // this path does not return through them; release on their behalf so ~VM — and with it + // Heap::lastChanceToFinalize — actually runs here. for (uint32_t n = vm.refCount(); n > 1; --n) vm.derefSuppressingSaferCPPChecking(); - // refCount 1 -> 0 runs ~VM; `vm` is dead past this line. vm.derefSuppressingSaferCPPChecking(); +} + +extern "C" void Zig__GlobalObject__prepareForDestruction(Zig::GlobalObject* globalObject) +{ + globalObject->prepareForDestruction(); +} + +extern "C" void Zig__GlobalObject__forbidExecution(Zig::GlobalObject* globalObject) +{ + globalObject->forbidExecution(); +} + +// `bun test --isolate`: the file that just finished is being retired on a live VM. Its context's +// workers, ports, channels and sockets are stopped before anything else of the file is swept. +extern "C" void Zig__GlobalObject__stopActiveDOMObjectsForTestIsolation(Zig::GlobalObject* globalObject) +{ + globalObject->scriptExecutionContext()->prepareForDestruction(); +} + +extern "C" void Zig__GlobalObject__destructOnExit(Zig::GlobalObject* globalObject) +{ + auto& vm = JSC::getVM(globalObject); + ASSERT(globalObject->scriptExecutionContext()->activeDOMObjectsAreStopped()); + vm.entryScope = nullptr; + Ref context = *globalObject->scriptExecutionContext(); + Ref runLoop = vm.runLoop(); + + Bun__InspectorConnection__disconnectAllOnExit(globalObject); + // Deferred promises / callbacks (DOMGuardedObject) hold JSC::Weak handles and observe the + // context; the context outlives ~VM here, so their handles are cleared now, with the heap + // alive — WebCore's ~WorkerOrWorkletScriptController does the same right before its VM goes. + globalObject->clearDOMGuardedObjects(); + gcUnprotect(globalObject); + globalObject = nullptr; + + destroyVM(vm); runLoop->threadWillExit(); + // `context` is released here, after ~VM: contextDestroyed() reaches observers at a defined + // point on this thread instead of from inside a GC destructor. +} + +extern "C" void WebWorker__teardownJSCVM(Zig::GlobalObject* globalObject) +{ + auto& vm = JSC::getVM(globalObject); + ASSERT(globalObject->scriptExecutionContext()->activeDOMObjectsAreStopped()); + Ref context = *globalObject->scriptExecutionContext(); + + vm.deleteAllCode(JSC::DeleteAllCodeEffort::PreventCollectionAndDeleteAllCode); + // See Zig__GlobalObject__destructOnExit. + globalObject->clearDOMGuardedObjects(); + gcUnprotect(globalObject); + globalObject = nullptr; + + destroyVM(vm); } #include "ZigGeneratedClasses+lazyStructureImpl.h" @@ -4289,9 +4402,9 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionCreateFunctionThatMasqueradesAsUndefined, (JS auto& vm = JSC::getVM(leixcalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); auto name = callFrame->argument(0).toWTFString(leixcalGlobalObject); - scope.assertNoException(); + RETURN_IF_EXCEPTION(scope, {}); auto count = callFrame->argument(1).toNumber(leixcalGlobalObject); - scope.assertNoException(); + RETURN_IF_EXCEPTION(scope, {}); auto* func = InternalFunction::createFunctionThatMasqueradesAsUndefined(vm, leixcalGlobalObject, count, name, jsFunctionNotImplemented); return JSC::JSValue::encode(func); } diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index abf6e834a36f..35a8a4f69ec6 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -20,11 +20,12 @@ enum class JSPromiseRejectionOperation : unsigned; } // namespace JSC namespace WebCore { +class MessagePort; class ScriptExecutionContext; class DOMGuardedObject; class EventLoopTask; class DOMWrapperWorld; -class WorkerGlobalScope; +class GlobalEventScope; class SubtleCrypto; class EventTarget; class Performance; @@ -80,7 +81,7 @@ class JSMockFunction; } namespace WebCore { -class WorkerGlobalScope; +class GlobalEventScope; class SubtleCrypto; class EventTarget; } @@ -181,9 +182,6 @@ class GlobalObject : public Bun::GlobalScope { WebCore::ScriptExecutionContext* scriptExecutionContext() const; - void queueTask(WebCore::EventLoopTask* task); - void queueTaskConcurrently(WebCore::EventLoopTask* task); - JSDOMStructureMap& structures() WTF_REQUIRES_LOCK(m_gcLock) { return m_structures; } JSDOMStructureMap& structures(NoLockingNecessaryTag) WTF_IGNORES_THREAD_SAFETY_ANALYSIS { @@ -349,6 +347,13 @@ class GlobalObject : public Bun::GlobalScope { bool hasProcessObject() const { return m_processObject.isInitialized(); } RefPtr performance(); + WebCore::Performance* existingPerformance() const { return m_performance.get(); } + + // VM teardown, in order: forbidExecution() (clear microtasks and module caches, forbid script, + // request termination) -> prepareForDestruction() (fence cross-thread producers, stop every + // ActiveDOMObject, strip listeners) -> the caller's own sweeps and child joins. + void prepareForDestruction(); + void forbidExecution(); Bun::Process* processObject() const { return m_processObject.getInitializedOnMainThread(this); } JSC::JSObject* processEnvObject() const { return m_processEnvObject.getInitializedOnMainThread(this); } @@ -377,7 +382,9 @@ class GlobalObject : public Bun::GlobalScope { WebCore::EventTarget& eventTarget(); WebCore::ScriptExecutionContext* m_scriptExecutionContext; - Ref globalEventScope; + Ref globalEventScope; + RefPtr m_nodeParentPort; + bool m_nodeWorkerEntrySettled { false }; void resetOnEachMicrotaskTick(); @@ -537,7 +544,7 @@ class GlobalObject : public Bun::GlobalScope { /* Supports getEnvironmentData() and setEnvironmentData(), and is cloned into newly-created */ \ /* Workers. Initialized in createNodeWorkerThreadsBinding. */ \ V(private, WriteBarrier, m_nodeWorkerEnvironmentData) \ - /* setupMainThreadPort's drain callback; run once by WebWorker__dispatchOnline */ \ + /* setupMainThreadPort's drain callback; run once by WebWorker__entrySettled */ \ /* after entry-module evaluation. Stored here (not on globalThis) so user code can't clobber it. */ \ V(private, WriteBarrier, m_nodeWorkerEntryEvaluatedHook) \ \ @@ -779,6 +786,15 @@ class GlobalObject : public Bun::GlobalScope { JSMap* nodeWorkerEnvironmentData() { return m_nodeWorkerEnvironmentData.get(); } void setNodeWorkerEnvironmentData(JSMap* data); + // node:worker_threads parentPort — the transferred MessagePort entangled with the parent + // Worker's public port. Messages it dispatches are mirrored onto globalEventScope so the + // Web Worker style (`self.onmessage` / global addEventListener) keeps working under a node Worker. + void setNodeParentPort(WebCore::MessagePort*); + WebCore::MessagePort* nodeParentPort() const { return m_nodeParentPort.get(); } + // A node worker's parentPort delivers nothing until the entry module has evaluated (node's + // ordering; a message must not run — or throw — while the entry that handles it is loading). + bool nodeWorkerEntrySettled() const { return m_nodeWorkerEntrySettled; } + void nodeWorkerEntryDidSettle(); JSObject* nodeWorkerEntryEvaluatedHook() { return m_nodeWorkerEntryEvaluatedHook.get(); } void setNodeWorkerEntryEvaluatedHook(JSObject* hook); diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 59fd92da7d05..4069e991a080 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -3138,6 +3138,15 @@ extern "C" JSC::EncodedJSValue Bun__JSValue__call(JSC::JSGlobalObject* globalObj ASSERT_WITH_MESSAGE(!vm.isCollectorBusyOnCurrentThread(), "Cannot call function inside a finalizer or while GC is running on same thread."); + // The native→JS boundary for the Rust side (Node: InternalMakeCallback's can_call_into_js; + // WebCore: JSEventListener's isJSExecutionForbidden): once the VM's stop was requested or + // teardown has forbidden script, a callback from any event source is a silent no-op rather + // than each source checking. + if (vm.executionForbidden() || !WebCore::clientData(vm)->scriptAllowed()) [[unlikely]] { + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(jsUndefined()); + } + JSC::JSValue jsObject = JSValue::decode(object); ASSERT_WITH_MESSAGE(jsObject, "Cannot call function with JSValue zero."); @@ -5150,11 +5159,6 @@ bool JSC__VM__isTerminationException(JSC::VM* vm, JSC::Exception* exception) return vm->isTerminationException(exception); } -[[ZIG_EXPORT(nothrow)]] -void JSC__VM__clearHasTerminationRequest(JSC::VM* vm) -{ - vm->clearHasTerminationRequest(); -} [[ZIG_EXPORT(nothrow)]] bool JSC__VM__hasTerminationRequest(JSC::VM* vm) { @@ -5166,6 +5170,23 @@ void JSC__VM__setExecutionForbidden(JSC::VM* arg0, bool arg1) (*arg0).setExecutionForbidden(); } +// JS thread. Make the VM's stop concrete on this thread: after this a TerminationException is +// pending (unless termination is currently deferred), whether or not the NeedTermination trap the +// requester fired had been serviced yet. What RETURN_IF_EXCEPTION would have done at the next check. +[[ZIG_EXPORT(nothrow)]] +void JSC__VM__ensureTerminationExceptionPending(JSC::VM* arg0) +{ + JSC::VM& vm = *arg0; + if (vm.hasPendingTerminationException()) + return; + if (!vm.hasTerminationRequest() && !vm.traps().needHandling(JSC::VMTraps::NeedTermination)) + vm.notifyNeedTermination(); + if (vm.hasTerminationRequest()) + vm.throwTerminationException(); + else + vm.traps().handleTraps(JSC::VMTraps::NeedTermination); +} + // These may be called concurrently from another thread. void JSC__VM__notifyNeedTermination(JSC::VM* arg0) { @@ -6285,13 +6306,12 @@ CPP_DECL [[ZIG_EXPORT(check_slow)]] uint32_t JSC__JSMap__size(JSC::JSMap* map, J return map->size(); } -CPP_DECL void JSC__VM__setControlFlowProfiler(JSC::VM* vm, bool isEnabled) +// Enable only: compiled instrumented code holds raw pointers into the profiler, +// so it lives as long as the VM (see JSInspectorProfiler.cpp). +CPP_DECL void JSC__VM__enableControlFlowProfiler(JSC::VM* vm) { - if (isEnabled) { + if (!vm->controlFlowProfiler()) vm->enableControlFlowProfiler(); - } else { - vm->disableControlFlowProfiler(); - } } CPP_DECL void JSC__VM__performOpportunisticallyScheduledTasks(JSC::VM* vm, double until) diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index 72e715a52649..208be793da65 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -310,10 +310,11 @@ CPP_DECL bool JSC__VM__isJITEnabled(); CPP_DECL void JSC__VM__notifyNeedDebuggerBreak(JSC::VM* arg0); CPP_DECL void JSC__VM__notifyNeedShellTimeoutCheck(JSC::VM* arg0); CPP_DECL void JSC__VM__notifyNeedTermination(JSC::VM* arg0); +CPP_DECL void JSC__VM__ensureTerminationExceptionPending(JSC::VM* arg0); CPP_DECL void JSC__VM__notifyNeedWatchdogCheck(JSC::VM* arg0); CPP_DECL void JSC__VM__releaseWeakRefs(JSC::VM* arg0); CPP_DECL size_t JSC__VM__runGC(JSC::VM* arg0, bool arg1); -CPP_DECL void JSC__VM__setControlFlowProfiler(JSC::VM* arg0, bool arg1); +CPP_DECL void JSC__VM__enableControlFlowProfiler(JSC::VM* arg0); CPP_DECL void JSC__VM__setExecutionForbidden(JSC::VM* arg0, bool arg1); CPP_DECL void JSC__VM__setExecutionTimeLimit(JSC::VM* arg0, double arg1); CPP_DECL void JSC__VM__shrinkFootprint(JSC::VM* arg0); @@ -650,6 +651,7 @@ ZIG_DECL void Bun__WebSocket__freeSSLConfig(void* sslConfig); ZIG_DECL void Bun__WebSocketClient__cancel(WebSocketClient* arg0); ZIG_DECL void Bun__WebSocketClient__close(WebSocketClient* arg0, uint16_t arg1, const ZigString* arg2); ZIG_DECL void Bun__WebSocketClient__finalize(WebSocketClient* arg0); +ZIG_DECL void Bun__WebSocketClient__dropConnectionWithoutCallback(WebSocketClient* arg0); ZIG_DECL void* Bun__WebSocketClient__init(CppWebSocket* arg0, void* arg1, JSC::JSGlobalObject* arg2, unsigned char* arg3, size_t arg4, const PerMessageDeflateParams* arg5, void* customSSLCtx); ZIG_DECL void Bun__WebSocketClient__writeBinaryData(WebSocketClient* arg0, const unsigned char* arg1, size_t arg2, unsigned char arg3); ZIG_DECL void Bun__WebSocketClient__writeString(WebSocketClient* arg0, const ZigString* arg1, unsigned char arg2); @@ -662,6 +664,7 @@ ZIG_DECL size_t Bun__WebSocketClient__memoryCost(WebSocketClient* arg0); ZIG_DECL void Bun__WebSocketClientTLS__cancel(WebSocketClientTLS* arg0); ZIG_DECL void Bun__WebSocketClientTLS__close(WebSocketClientTLS* arg0, uint16_t arg1, const ZigString* arg2); ZIG_DECL void Bun__WebSocketClientTLS__finalize(WebSocketClientTLS* arg0); +ZIG_DECL void Bun__WebSocketClientTLS__dropConnectionWithoutCallback(WebSocketClientTLS* arg0); ZIG_DECL void* Bun__WebSocketClientTLS__init(CppWebSocket* arg0, void* arg1, JSC::JSGlobalObject* arg2, unsigned char* arg3, size_t arg4, const PerMessageDeflateParams* arg5, void* customSSLCtx); ZIG_DECL void Bun__WebSocketClientTLS__writeBinaryData(WebSocketClientTLS* arg0, const unsigned char* arg1, size_t arg2, unsigned char arg3); ZIG_DECL void Bun__WebSocketClientTLS__writeString(WebSocketClientTLS* arg0, const ZigString* arg1, unsigned char arg2); diff --git a/src/jsc/bindings/napi.cpp b/src/jsc/bindings/napi.cpp index 548f5eb03171..1daf97c087d0 100644 --- a/src/jsc/bindings/napi.cpp +++ b/src/jsc/bindings/napi.cpp @@ -16,6 +16,7 @@ #include "napi_type_tag.h" #include "helpers.h" +#include #include #include #include @@ -105,16 +106,16 @@ using namespace Zig; NAPI_CHECK_ARG(_env, _env); \ } while (0) -// Like NAPI_PREAMBLE but does NOT return napi_pending_exception when the env -// has a stashed napi_throw* exception. Mirrors Node.js's CHECK_ENV_NOT_IN_GC -// for pure value constructors/accessors that are safe to call while an -// exception is pending. Still declares a throw scope so NAPI_RETURN_SUCCESS -// can assert and VM-level exceptions from JSC internals are caught. -#define NAPI_PREAMBLE_NO_PENDING_CHECK(_env) \ - NAPI_LOG_CURRENT_FUNCTION; \ - NAPI_CHECK_ARG(_env, _env); \ - auto napi_preamble_throw_scope__ = DECLARE_TOP_EXCEPTION_SCOPE(_env->vm()); \ - NAPI_RETURN_IF_VM_EXCEPTION(_env) +// Like NAPI_PREAMBLE but for pure value constructors/accessors, which Node lets an addon call while +// an exception is pending (CHECK_ENV_NOT_IN_GC only) — node-addon-api relies on that to build the +// Error it wraps a failed call in. Any exception already on the VM (a napi_throw*, or a termination +// request that materialised in an earlier call while a worker is being stopped) is stashed for the +// duration and restored on return; the throw scope still catches what the body itself raises. +#define NAPI_PREAMBLE_NO_PENDING_CHECK(_env) \ + NAPI_LOG_CURRENT_FUNCTION; \ + NAPI_CHECK_ARG(_env, _env); \ + JSC::SuspendExceptionScope napi_preamble_suspended_exception__ { _env->vm() }; \ + auto napi_preamble_throw_scope__ = DECLARE_TOP_EXCEPTION_SCOPE(_env->vm()); // Return an error code if arg is null. Only use for input validation. #define NAPI_CHECK_ARG(_env, arg) \ @@ -2292,6 +2293,10 @@ extern "C" napi_status napi_create_buffer(napi_env env, size_t length, // armed only after the wrapping JS object (JSUint8Array / JSArrayBuffer) // is successfully created. If creation throws, the destructor runs // disarmed and skips finalize_cb so the caller retains ownership. +// Once armed, the addon's finalizer runs exactly once: when the buffer's contents die, or — +// if the env is torn down first (a Worker exiting while the addon still holds the buffer) — +// from NapiEnv::cleanup() together with the other bound finalizers, as Node's env teardown +// finalizes every remaining reference (test_worker_buffer_callback/test-free-called). class NapiExternalBufferDestructor final : public SharedTask { public: NapiExternalBufferDestructor(WTF::Ref&& env, napi_finalize cb, void* hint) @@ -2301,21 +2306,44 @@ class NapiExternalBufferDestructor final : public SharedTask { { } + // The contents died (GC, or the heap going away). void run(void* data) override { - if (m_armed) { - NAPI_LOG("external buffer finalizer"); - m_env->doFinalizer(m_cb, data, m_hint); + if (!m_armed || m_finalized) + return; + m_finalized = true; + if (m_bound) { + m_bound->deactivate(m_env.get()); + m_bound = nullptr; } + NAPI_LOG("external buffer finalizer"); + m_env->doFinalizer(m_cb, data, m_hint); } - void arm() { m_armed = true; } + void arm(void* data) + { + m_armed = true; + if (m_cb) + m_bound = &m_env->addFinalizer(finalizeAtEnvCleanup, this, data); + } private: + // NapiEnv::cleanup(): the env goes before the buffer did. `hint` is this destructor, alive + // because the contents it belongs to still are (run() unbinds it before they go). + static void finalizeAtEnvCleanup(napi_env env, void* data, void* hint) + { + auto* self = static_cast(hint); + self->m_bound = nullptr; + self->m_finalized = true; + self->m_cb(env, data, self->m_hint); + } + WTF::Ref m_env; napi_finalize m_cb; void* m_hint; + const NapiEnv::BoundFinalizer* m_bound { nullptr }; bool m_armed { false }; + bool m_finalized { false }; }; extern "C" napi_status napi_create_external_buffer(napi_env env, size_t length, @@ -2360,7 +2388,7 @@ extern "C" napi_status napi_create_external_buffer(napi_env env, size_t length, // Arm only after successful creation: if create threw, the destructor // runs disarmed and skips finalize_cb (caller retains ownership). - destructorPtr->arm(); + destructorPtr->arm(data); *result = toNapi(buffer, globalObject); NAPI_RETURN_SUCCESS(env); @@ -2392,7 +2420,7 @@ extern "C" napi_status napi_create_external_arraybuffer(napi_env env, void* exte auto* buffer = JSC::JSArrayBuffer::create(vm, globalObject->arrayBufferStructure(ArrayBufferSharingMode::Default), WTF::move(arrayBuffer)); // Arm only after successful creation so that if a future change makes // create() throw, the destructor runs disarmed and skips finalize_cb. - destructorPtr->arm(); + destructorPtr->arm(external_data); *result = toNapi(buffer, globalObject); NAPI_RETURN_SUCCESS(env); @@ -3327,6 +3355,11 @@ extern "C" bool NapiEnv__getAndClearPendingException(napi_env env, JSC::EncodedJ return false; } +extern "C" const ::BunVmHandleRef* NapiEnv__vmHandle(napi_env env) +{ + return env->vmHandle(); +} + extern "C" void NapiEnv__ref(napi_env env) { env->ref(); diff --git a/src/jsc/bindings/napi.h b/src/jsc/bindings/napi.h index f7896c847b73..dd96d7688e51 100644 --- a/src/jsc/bindings/napi.h +++ b/src/jsc/bindings/napi.h @@ -181,6 +181,7 @@ struct NapiEnv : public WTF::RefCounted { : m_globalObject(globalObject) , m_napiModule(napiModule) , m_vm(JSC::getVM(globalObject)) + , m_vmHandle(Bun__VmHandle__retainRef(WebCore::clientData(JSC::getVM(globalObject))->vmHandle)) { napi_internal_register_cleanup_zig(this); } @@ -193,8 +194,14 @@ struct NapiEnv : public WTF::RefCounted { ~NapiEnv() { delete[] filename; + Bun__VmHandle__release(m_vmHandle); } + // This env's own clone of its VM's handle: how a thread holding an env ref + // (a finalizer fired off the JS thread) posts work to the VM. Lives as long + // as the env, which can outlive the JSC VM's client data. + const ::BunVmHandleRef* vmHandle() const { return m_vmHandle; } + void cleanup() { // The VM can already have a pending exception when cleanup starts: @@ -234,20 +241,28 @@ struct NapiEnv : public WTF::RefCounted { m_cleanupHooks = Napi::HookSet(); clearExceptionsBetweenFinalizers(); - // Defer GC during entire finalizer cleanup to prevent iterator invalidation. - // This prevents any GC-triggered finalizer execution while m_finalizers is being iterated. - JSC::DeferGCForAWhile deferGC(m_vm); - m_isFinishingFinalizers = true; // A cleanup hook may itself have leaked an exception; the first // finalizer starts clean too. clearExceptionsBetweenFinalizers(); - // Reverse insertion order so children are torn down before parents (Node.js LIFO). - // ListHashSet iteration is safe against concurrent inserts, and m_isFinishingFinalizers - // routes all removals to active=false, so the only unsafe op (erase-current) can't occur. - for (auto it = m_finalizers.rbegin(); it != m_finalizers.rend(); ++it) { - Bun::NapiHandleScope handle_scope(m_globalObject); - it->call(this); + // Drain to empty, last first, so children are torn down before parents (Node.js + // LIFO) and a finalizer that registers another finalizer while running (an addon + // creating an external buffer from a finalizer) has it run in this same cleanup + // rather than left behind. The entry being called stays in the set until it + // returns — its owner (NapiRef / external-buffer destructor) holds a pointer to that + // node and may deactivate() it from inside the call — and is removed afterwards; no + // iterator is held across a call, so inserts and removals during one are plain. + while (!m_finalizers.isEmpty()) { + const BoundFinalizer* current = &m_finalizers.last(); + m_currentFinalizer = current; + { + Bun::NapiHandleScope handle_scope(m_globalObject); + current->call(this); + } + m_currentFinalizer = nullptr; + // Whatever the call appended sits after `current`; remove `current` itself by value + // (still a live node: deactivate() only marks the running entry). + m_finalizers.remove(*current); // Each finalizer starts from a clean exception state: Node.js // never propagates one finalizer's throw into the next (there // is no JS frame to catch in between). Leaving a pending @@ -257,7 +272,6 @@ struct NapiEnv : public WTF::RefCounted { // the next napi call with a throw scope sees it. See #30286. clearExceptionsBetweenFinalizers(); } - m_finalizers.clear(); m_isFinishingFinalizers = false; instanceDataFinalizer.call(this, instanceData, true); @@ -299,18 +313,25 @@ struct NapiEnv : public WTF::RefCounted { } } - void removeFinalizer(napi_finalize callback, void* hint, void* data) - { - m_finalizers.remove({ callback, hint, data }); - } - struct BoundFinalizer; + // The entry cleanup() is currently calling stays in the set until its call returns (its + // owner holds a pointer to that node); asking to remove it marks it instead. Any other + // entry is removed outright. void removeFinalizer(const BoundFinalizer& finalizer) { + if (m_currentFinalizer && *m_currentFinalizer == finalizer) { + m_currentFinalizer->active = false; + return; + } m_finalizers.remove(finalizer); } + void removeFinalizer(napi_finalize callback, void* hint, void* data) + { + removeFinalizer(BoundFinalizer { callback, hint, data }); + } + const auto& addFinalizer(napi_finalize callback, void* hint, void* data) { return *m_finalizers.add({ callback, hint, data }).iterator; @@ -502,6 +523,8 @@ struct NapiEnv : public WTF::RefCounted { } inline bool isFinishingFinalizers() const { return m_isFinishingFinalizers; } + // The entry cleanup() is currently calling, if any (see BoundFinalizer::deactivate). + inline const BoundFinalizer* currentFinalizer() const { return m_currentFinalizer; } // Almost all NAPI functions should set error_code to the status they're returning right before // they return it @@ -528,8 +551,8 @@ struct NapiEnv : public WTF::RefCounted { napi_finalize callback = nullptr; void* hint = nullptr; void* data = nullptr; - // Allows bound finalizers to effectively remove themselves during cleanup without breaking iteration. - // Safe to be mutable because it's not included in the hash. + // The running entry cannot leave the set until its call returns; deactivating it from + // inside the call marks it instead. Not part of the hash. mutable bool active = true; BoundFinalizer() = default; @@ -557,13 +580,9 @@ struct NapiEnv : public WTF::RefCounted { void deactivate(NapiEnv& env) const { - if (env.isFinishingFinalizers()) { - active = false; - } else { - env.removeFinalizer(*this); - // At this point the BoundFinalizer has been destroyed, but because we're not doing anything else here it's safe. - // https://isocpp.org/wiki/faq/freestore-mgmt#delete-this - } + // `*this` may be the set's own node: nothing is touched after the removal. + // https://isocpp.org/wiki/faq/freestore-mgmt#delete-this + env.removeFinalizer(*this); } bool operator==(const BoundFinalizer& other) const @@ -590,8 +609,10 @@ struct NapiEnv : public WTF::RefCounted { // ListHashSet preserves insertion order so cleanup() can run finalizers in reverse // (LIFO), matching Node.js teardown semantics for napi_wrap references. WTF::ListHashSet m_finalizers; + const BoundFinalizer* m_currentFinalizer = nullptr; bool m_isFinishingFinalizers = false; JSC::VM& m_vm; + const ::BunVmHandleRef* m_vmHandle; Napi::HookSet m_cleanupHooks; JSC::Strong m_pendingException; size_t m_cleanupHookCounter = 0; diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp index e7d895f5e3b8..441e75d4fbe4 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp @@ -6,6 +6,7 @@ #include "ScriptExecutionContext.h" #include "helpers.h" #include "JSSocketAddressDTO.h" +#include #include #include #include @@ -672,12 +673,20 @@ void JSNodeHTTPServerSocket::onClose() EnsureStillAliveScope ensureStillAlive(self); if (globalObject->scriptExecutionStatus(globalObject, thisObject) == ScriptExecutionStatus::Running) { + // Notifying the responses runs script; it may leave an exception (a + // termination arriving meanwhile), and nothing is entered on top of one. + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); notifyResponsesOnClose(thisObject); - - profiledCall(globalObject, JSC::ProfilingReason::API, callbackObject, callData, thisObject, args, exception); - - if (auto* ptr = exception.get()) { - exception.clear(); + if (!scope.exception()) { + profiledCall(globalObject, JSC::ProfilingReason::API, callbackObject, callData, thisObject, args, exception); + if (auto* ptr = exception.get()) { + exception.clear(); + globalObject->reportUncaughtExceptionAtEventLoop(globalObject, ptr); + } + } else if (!vm.hasPendingTerminationException()) { + auto* ptr = scope.exception(); + scope.clearException(); globalObject->reportUncaughtExceptionAtEventLoop(globalObject, ptr); } } diff --git a/src/jsc/bindings/node/crypto/CryptoDhJob.cpp b/src/jsc/bindings/node/crypto/CryptoDhJob.cpp index 1b9400e2c966..86258f12fe24 100644 --- a/src/jsc/bindings/node/crypto/CryptoDhJob.cpp +++ b/src/jsc/bindings/node/crypto/CryptoDhJob.cpp @@ -61,19 +61,6 @@ JSCallbackArgs DhJobCtx::runFromJS(JSGlobalObject* lexicalGlobalObject) return { jsNull(), result }; } -extern "C" DhJob* Bun__DhJob__create(JSGlobalObject* globalObject, DhJobCtx* ctx, EncodedJSValue callback); -DhJob* DhJob::create(JSGlobalObject* globalObject, DhJobCtx&& ctx, JSValue callback) -{ - DhJobCtx* ctxCopy = new DhJobCtx(WTF::move(ctx)); - return Bun__DhJob__create(globalObject, ctxCopy, JSValue::encode(callback)); -} - -extern "C" void Bun__DhJob__schedule(DhJob* job); -void DhJob::schedule() -{ - Bun__DhJob__schedule(this); -} - extern "C" void Bun__DhJob__createAndSchedule(JSGlobalObject* globalObject, DhJobCtx* ctx, EncodedJSValue callback); void DhJob::createAndSchedule(JSGlobalObject* globalObject, DhJobCtx&& ctx, JSValue callback) { diff --git a/src/jsc/bindings/node/crypto/CryptoDhJob.h b/src/jsc/bindings/node/crypto/CryptoDhJob.h index 4debc283b01d..e9a9aefc53ed 100644 --- a/src/jsc/bindings/node/crypto/CryptoDhJob.h +++ b/src/jsc/bindings/node/crypto/CryptoDhJob.h @@ -41,9 +41,7 @@ struct DhJobCtx { }; struct DhJob { - static DhJob* create(JSC::JSGlobalObject*, DhJobCtx&&, JSC::JSValue callback); static void createAndSchedule(JSC::JSGlobalObject*, DhJobCtx&&, JSC::JSValue callback); - void schedule(); }; } // namespace Bun diff --git a/src/jsc/bindings/node/crypto/CryptoGenDhKeyPair.cpp b/src/jsc/bindings/node/crypto/CryptoGenDhKeyPair.cpp index e31d9101704b..aaf32f297e15 100644 --- a/src/jsc/bindings/node/crypto/CryptoGenDhKeyPair.cpp +++ b/src/jsc/bindings/node/crypto/CryptoGenDhKeyPair.cpp @@ -30,19 +30,6 @@ extern "C" void Bun__DhKeyPairJobCtx__runFromJS(DhKeyPairJobCtx* ctx, JSGlobalOb *out = ctx->runFromJS(globalObject); } -extern "C" DhKeyPairJob* Bun__DhKeyPairJob__create(JSGlobalObject* globalObject, DhKeyPairJobCtx* ctx, EncodedJSValue callback); -DhKeyPairJob* DhKeyPairJob::create(JSGlobalObject* globalObject, DhKeyPairJobCtx&& ctx, JSValue callback) -{ - DhKeyPairJobCtx* ctxCopy = new DhKeyPairJobCtx(WTF::move(ctx)); - return Bun__DhKeyPairJob__create(globalObject, ctxCopy, JSValue::encode(callback)); -} - -extern "C" void Bun__DhKeyPairJob__schedule(DhKeyPairJob* job); -void DhKeyPairJob::schedule() -{ - Bun__DhKeyPairJob__schedule(this); -} - extern "C" void Bun__DhKeyPairJob__createAndSchedule(JSGlobalObject* globalObject, DhKeyPairJobCtx* ctx, EncodedJSValue callback); void DhKeyPairJob::createAndSchedule(JSGlobalObject* globalObject, DhKeyPairJobCtx&& ctx, JSValue callback) { diff --git a/src/jsc/bindings/node/crypto/CryptoGenDhKeyPair.h b/src/jsc/bindings/node/crypto/CryptoGenDhKeyPair.h index 9ea6fa7cea0f..7f0e25c74b70 100644 --- a/src/jsc/bindings/node/crypto/CryptoGenDhKeyPair.h +++ b/src/jsc/bindings/node/crypto/CryptoGenDhKeyPair.h @@ -38,9 +38,7 @@ struct DhKeyPairJobCtx : KeyPairJobCtx { }; struct DhKeyPairJob { - static DhKeyPairJob* create(JSC::JSGlobalObject*, DhKeyPairJobCtx&&, JSC::JSValue callback); static void createAndSchedule(JSC::JSGlobalObject*, DhKeyPairJobCtx&&, JSC::JSValue callback); - void schedule(); }; } // namespace Bun diff --git a/src/jsc/bindings/node/crypto/CryptoGenDsaKeyPair.cpp b/src/jsc/bindings/node/crypto/CryptoGenDsaKeyPair.cpp index e992b05eb0f7..182fd2306da6 100644 --- a/src/jsc/bindings/node/crypto/CryptoGenDsaKeyPair.cpp +++ b/src/jsc/bindings/node/crypto/CryptoGenDsaKeyPair.cpp @@ -29,19 +29,6 @@ extern "C" void Bun__DsaKeyPairJobCtx__runFromJS(DsaKeyPairJobCtx* ctx, JSGlobal *out = ctx->runFromJS(globalObject); } -extern "C" DsaKeyPairJob* Bun__DsaKeyPairJob__create(JSGlobalObject* globalObject, DsaKeyPairJobCtx* ctx, EncodedJSValue callback); -DsaKeyPairJob* DsaKeyPairJob::create(JSGlobalObject* globalObject, DsaKeyPairJobCtx&& ctx, JSValue callback) -{ - DsaKeyPairJobCtx* ctxCopy = new DsaKeyPairJobCtx(WTF::move(ctx)); - return Bun__DsaKeyPairJob__create(globalObject, ctxCopy, JSValue::encode(callback)); -} - -extern "C" void Bun__DsaKeyPairJob__schedule(DsaKeyPairJob* job); -void DsaKeyPairJob::schedule() -{ - Bun__DsaKeyPairJob__schedule(this); -} - extern "C" void Bun__DsaKeyPairJob__createAndSchedule(JSGlobalObject* globalObject, DsaKeyPairJobCtx* ctx, EncodedJSValue callback); void DsaKeyPairJob::createAndSchedule(JSGlobalObject* globalObject, DsaKeyPairJobCtx&& ctx, JSValue callback) { diff --git a/src/jsc/bindings/node/crypto/CryptoGenDsaKeyPair.h b/src/jsc/bindings/node/crypto/CryptoGenDsaKeyPair.h index 3d82fe05f2d9..5ae646396ed3 100644 --- a/src/jsc/bindings/node/crypto/CryptoGenDsaKeyPair.h +++ b/src/jsc/bindings/node/crypto/CryptoGenDsaKeyPair.h @@ -26,9 +26,7 @@ struct DsaKeyPairJobCtx : KeyPairJobCtx { }; struct DsaKeyPairJob { - static DsaKeyPairJob* create(JSC::JSGlobalObject*, DsaKeyPairJobCtx&&, JSC::JSValue callback); static void createAndSchedule(JSC::JSGlobalObject*, DsaKeyPairJobCtx&&, JSC::JSValue callback); - void schedule(); }; } // namespace Bun diff --git a/src/jsc/bindings/node/crypto/CryptoGenEcKeyPair.cpp b/src/jsc/bindings/node/crypto/CryptoGenEcKeyPair.cpp index 4df1f1927b41..6757a16c6b61 100644 --- a/src/jsc/bindings/node/crypto/CryptoGenEcKeyPair.cpp +++ b/src/jsc/bindings/node/crypto/CryptoGenEcKeyPair.cpp @@ -30,19 +30,6 @@ extern "C" void Bun__EcKeyPairJobCtx__runFromJS(EcKeyPairJobCtx* ctx, JSGlobalOb *out = ctx->runFromJS(globalObject); } -extern "C" EcKeyPairJob* Bun__EcKeyPairJob__create(JSGlobalObject* globalObject, EcKeyPairJobCtx* ctx, EncodedJSValue callback); -EcKeyPairJob* EcKeyPairJob::create(JSGlobalObject* globalObject, EcKeyPairJobCtx&& ctx, JSValue callback) -{ - EcKeyPairJobCtx* ctxCopy = new EcKeyPairJobCtx(WTF::move(ctx)); - return Bun__EcKeyPairJob__create(globalObject, ctxCopy, JSValue::encode(callback)); -} - -extern "C" void Bun__EcKeyPairJob__schedule(EcKeyPairJob* job); -void EcKeyPairJob::schedule() -{ - Bun__EcKeyPairJob__schedule(this); -} - extern "C" void Bun__EcKeyPairJob__createAndSchedule(JSGlobalObject* globalObject, EcKeyPairJobCtx* ctx, EncodedJSValue callback); void EcKeyPairJob::createAndSchedule(JSGlobalObject* globalObject, EcKeyPairJobCtx&& ctx, JSValue callback) { diff --git a/src/jsc/bindings/node/crypto/CryptoGenEcKeyPair.h b/src/jsc/bindings/node/crypto/CryptoGenEcKeyPair.h index 87dca842bf4c..fbca22bf0bfb 100644 --- a/src/jsc/bindings/node/crypto/CryptoGenEcKeyPair.h +++ b/src/jsc/bindings/node/crypto/CryptoGenEcKeyPair.h @@ -26,9 +26,7 @@ struct EcKeyPairJobCtx : KeyPairJobCtx { }; struct EcKeyPairJob { - static EcKeyPairJob* create(JSC::JSGlobalObject*, EcKeyPairJobCtx&&, JSC::JSValue callback); static void createAndSchedule(JSC::JSGlobalObject*, EcKeyPairJobCtx&&, JSC::JSValue callback); - void schedule(); }; } // namespace Bun diff --git a/src/jsc/bindings/node/crypto/CryptoGenNidKeyPair.cpp b/src/jsc/bindings/node/crypto/CryptoGenNidKeyPair.cpp index 405413a109c1..6b49c9d10824 100644 --- a/src/jsc/bindings/node/crypto/CryptoGenNidKeyPair.cpp +++ b/src/jsc/bindings/node/crypto/CryptoGenNidKeyPair.cpp @@ -30,19 +30,6 @@ extern "C" void Bun__NidKeyPairJobCtx__runFromJS(NidKeyPairJobCtx* ctx, JSGlobal *out = ctx->runFromJS(globalObject); } -extern "C" NidKeyPairJob* Bun__NidKeyPairJob__create(JSGlobalObject* globalObject, NidKeyPairJobCtx* ctx, EncodedJSValue callback); -NidKeyPairJob* NidKeyPairJob::create(JSGlobalObject* globalObject, NidKeyPairJobCtx&& ctx, JSValue callback) -{ - NidKeyPairJobCtx* ctxCopy = new NidKeyPairJobCtx(WTF::move(ctx)); - return Bun__NidKeyPairJob__create(globalObject, ctxCopy, JSValue::encode(callback)); -} - -extern "C" void Bun__NidKeyPairJob__schedule(NidKeyPairJob* job); -void NidKeyPairJob::schedule() -{ - Bun__NidKeyPairJob__schedule(this); -} - extern "C" void Bun__NidKeyPairJob__createAndSchedule(JSGlobalObject* globalObject, NidKeyPairJobCtx* ctx, EncodedJSValue callback); void NidKeyPairJob::createAndSchedule(JSGlobalObject* globalObject, NidKeyPairJobCtx&& ctx, JSValue callback) { diff --git a/src/jsc/bindings/node/crypto/CryptoGenNidKeyPair.h b/src/jsc/bindings/node/crypto/CryptoGenNidKeyPair.h index 4f4e7207dcb0..e63dfaca94e6 100644 --- a/src/jsc/bindings/node/crypto/CryptoGenNidKeyPair.h +++ b/src/jsc/bindings/node/crypto/CryptoGenNidKeyPair.h @@ -24,9 +24,7 @@ struct NidKeyPairJobCtx : KeyPairJobCtx { }; struct NidKeyPairJob { - static NidKeyPairJob* create(JSC::JSGlobalObject*, NidKeyPairJobCtx&&, JSC::JSValue callback); static void createAndSchedule(JSC::JSGlobalObject*, NidKeyPairJobCtx&&, JSC::JSValue callback); - void schedule(); }; } // namespace Bun diff --git a/src/jsc/bindings/node/crypto/CryptoGenRsaKeyPair.cpp b/src/jsc/bindings/node/crypto/CryptoGenRsaKeyPair.cpp index b9d16ac67fec..be1d5c4e4fee 100644 --- a/src/jsc/bindings/node/crypto/CryptoGenRsaKeyPair.cpp +++ b/src/jsc/bindings/node/crypto/CryptoGenRsaKeyPair.cpp @@ -31,19 +31,6 @@ extern "C" void Bun__RsaKeyPairJobCtx__runFromJS(RsaKeyPairJobCtx* ctx, JSGlobal *out = ctx->runFromJS(globalObject); } -extern "C" RsaKeyPairJob* Bun__RsaKeyPairJob__create(JSGlobalObject* globalObject, RsaKeyPairJobCtx* ctx, EncodedJSValue callback); -RsaKeyPairJob* RsaKeyPairJob::create(JSGlobalObject* globalObject, RsaKeyPairJobCtx&& ctx, JSValue callback) -{ - RsaKeyPairJobCtx* ctxCopy = new RsaKeyPairJobCtx(WTF::move(ctx)); - return Bun__RsaKeyPairJob__create(globalObject, ctxCopy, JSValue::encode(callback)); -} - -extern "C" void Bun__RsaKeyPairJob__schedule(RsaKeyPairJob* job); -void RsaKeyPairJob::schedule() -{ - Bun__RsaKeyPairJob__schedule(this); -} - extern "C" void Bun__RsaKeyPairJob__createAndSchedule(JSGlobalObject* globalObject, RsaKeyPairJobCtx* ctx, EncodedJSValue callback); void RsaKeyPairJob::createAndSchedule(JSGlobalObject* globalObject, RsaKeyPairJobCtx&& ctx, JSValue callback) { diff --git a/src/jsc/bindings/node/crypto/CryptoGenRsaKeyPair.h b/src/jsc/bindings/node/crypto/CryptoGenRsaKeyPair.h index 69f16eb51b82..925b04ea89f5 100644 --- a/src/jsc/bindings/node/crypto/CryptoGenRsaKeyPair.h +++ b/src/jsc/bindings/node/crypto/CryptoGenRsaKeyPair.h @@ -60,9 +60,7 @@ struct RsaKeyPairJobCtx : KeyPairJobCtx { }; struct RsaKeyPairJob { - static RsaKeyPairJob* create(JSC::JSGlobalObject*, RsaKeyPairJobCtx&&, JSC::JSValue callback); static void createAndSchedule(JSC::JSGlobalObject*, RsaKeyPairJobCtx&&, JSC::JSValue callback); - void schedule(); }; } // namespace Bun diff --git a/src/jsc/bindings/node/crypto/CryptoHkdf.cpp b/src/jsc/bindings/node/crypto/CryptoHkdf.cpp index c05d3e33a580..493f5cc984df 100644 --- a/src/jsc/bindings/node/crypto/CryptoHkdf.cpp +++ b/src/jsc/bindings/node/crypto/CryptoHkdf.cpp @@ -108,19 +108,6 @@ void HkdfJobCtx::deinit() delete this; } -extern "C" HkdfJob* Bun__HkdfJob__create(JSGlobalObject* globalObject, HkdfJobCtx* ctx, EncodedJSValue callback); -HkdfJob* HkdfJob::create(JSGlobalObject* globalObject, HkdfJobCtx&& ctx, JSValue callback) -{ - HkdfJobCtx* ctxCopy = new HkdfJobCtx(WTF::move(ctx)); - return Bun__HkdfJob__create(globalObject, ctxCopy, JSValue::encode(callback)); -} - -extern "C" void Bun__HkdfJob__schedule(HkdfJob* job); -void HkdfJob::schedule() -{ - Bun__HkdfJob__schedule(this); -} - extern "C" void Bun__HkdfJob__createAndSchedule(JSGlobalObject* globalObject, HkdfJobCtx* ctx, EncodedJSValue callback); void HkdfJob::createAndSchedule(JSGlobalObject* globalObject, HkdfJobCtx&& ctx, JSValue callback) { diff --git a/src/jsc/bindings/node/crypto/CryptoHkdf.h b/src/jsc/bindings/node/crypto/CryptoHkdf.h index 06d0d4a86365..eec324fd8254 100644 --- a/src/jsc/bindings/node/crypto/CryptoHkdf.h +++ b/src/jsc/bindings/node/crypto/CryptoHkdf.h @@ -41,9 +41,7 @@ struct HkdfJobCtx { }; struct HkdfJob { - static HkdfJob* create(JSC::JSGlobalObject*, HkdfJobCtx&&, JSC::JSValue callback); static void createAndSchedule(JSC::JSGlobalObject*, HkdfJobCtx&&, JSC::JSValue callback); - void schedule(); }; } // namespace Bun diff --git a/src/jsc/bindings/node/crypto/CryptoKeygen.cpp b/src/jsc/bindings/node/crypto/CryptoKeygen.cpp index d44bd588db5a..c8afde6daaaa 100644 --- a/src/jsc/bindings/node/crypto/CryptoKeygen.cpp +++ b/src/jsc/bindings/node/crypto/CryptoKeygen.cpp @@ -70,19 +70,6 @@ void SecretKeyJobCtx::deinit() delete this; } -extern "C" SecretKeyJob* Bun__SecretKeyJob__create(JSC::JSGlobalObject*, SecretKeyJobCtx*, EncodedJSValue callback); -SecretKeyJob* SecretKeyJob::create(JSC::JSGlobalObject* lexicalGlobalObject, size_t length, JSC::JSValue callback) -{ - SecretKeyJobCtx* ctx = new SecretKeyJobCtx(length); - return Bun__SecretKeyJob__create(lexicalGlobalObject, ctx, JSValue::encode(callback)); -} - -extern "C" void Bun__SecretKeyJob__schedule(SecretKeyJob* job); -void SecretKeyJob::schedule() -{ - Bun__SecretKeyJob__schedule(this); -} - extern "C" void Bun__SecretKeyJob__createAndSchedule(JSC::JSGlobalObject*, SecretKeyJobCtx*, EncodedJSValue callback); void SecretKeyJob::createAndSchedule(JSC::JSGlobalObject* lexicalGlobalObject, SecretKeyJobCtx&& ctx, JSC::JSValue callback) { diff --git a/src/jsc/bindings/node/crypto/CryptoKeygen.h b/src/jsc/bindings/node/crypto/CryptoKeygen.h index f952fbeb838a..b4145de623e6 100644 --- a/src/jsc/bindings/node/crypto/CryptoKeygen.h +++ b/src/jsc/bindings/node/crypto/CryptoKeygen.h @@ -26,10 +26,7 @@ struct SecretKeyJobCtx { }; struct SecretKeyJob { - static SecretKeyJob* create(JSC::JSGlobalObject*, size_t length, JSC::JSValue callback); static void createAndSchedule(JSC::JSGlobalObject*, SecretKeyJobCtx&&, JSC::JSValue callback); - - void schedule(); }; JSC_DECLARE_HOST_FUNCTION(jsGenerateKey); diff --git a/src/jsc/bindings/node/crypto/CryptoPrimes.cpp b/src/jsc/bindings/node/crypto/CryptoPrimes.cpp index f55096657616..8a872d890634 100644 --- a/src/jsc/bindings/node/crypto/CryptoPrimes.cpp +++ b/src/jsc/bindings/node/crypto/CryptoPrimes.cpp @@ -51,19 +51,6 @@ void CheckPrimeJobCtx::deinit() delete this; } -extern "C" CheckPrimeJob* Bun__CheckPrimeJob__create(JSGlobalObject*, CheckPrimeJobCtx*, EncodedJSValue callback); -CheckPrimeJob* CheckPrimeJob::create(JSGlobalObject* globalObject, ncrypto::BignumPointer candidate, int32_t checks, JSValue callback) -{ - CheckPrimeJobCtx* ctx = new CheckPrimeJobCtx(WTF::move(candidate), checks); - return Bun__CheckPrimeJob__create(globalObject, ctx, JSValue::encode(callback)); -} - -extern "C" void Bun__CheckPrimeJob__schedule(CheckPrimeJob*); -void CheckPrimeJob::schedule() -{ - Bun__CheckPrimeJob__schedule(this); -} - extern "C" void Bun__CheckPrimeJob__createAndSchedule(JSGlobalObject*, CheckPrimeJobCtx*, EncodedJSValue callback); void CheckPrimeJob::createAndSchedule(JSGlobalObject* globalObject, ncrypto::BignumPointer candidate, int32_t checks, JSValue callback) { @@ -226,19 +213,6 @@ void GeneratePrimeJobCtx::deinit() delete this; } -extern "C" GeneratePrimeJob* Bun__GeneratePrimeJob__create(JSGlobalObject*, GeneratePrimeJobCtx*, EncodedJSValue callback); -GeneratePrimeJob* GeneratePrimeJob::create(JSGlobalObject* globalObject, int32_t size, bool safe, ncrypto::BignumPointer prime, ncrypto::BignumPointer add, ncrypto::BignumPointer rem, bool bigint, JSValue callback) -{ - GeneratePrimeJobCtx* ctx = new GeneratePrimeJobCtx(size, safe, WTF::move(prime), WTF::move(add), WTF::move(rem), bigint); - return Bun__GeneratePrimeJob__create(globalObject, ctx, JSValue::encode(callback)); -} - -extern "C" void Bun__GeneratePrimeJob__schedule(GeneratePrimeJob*); -void GeneratePrimeJob::schedule() -{ - Bun__GeneratePrimeJob__schedule(this); -} - extern "C" void Bun__GeneratePrimeJob__createAndSchedule(JSGlobalObject*, GeneratePrimeJobCtx*, EncodedJSValue callback); void GeneratePrimeJob::createAndSchedule(JSGlobalObject* globalObject, int32_t size, bool safe, ncrypto::BignumPointer prime, ncrypto::BignumPointer add, ncrypto::BignumPointer rem, bool bigint, JSValue callback) { diff --git a/src/jsc/bindings/node/crypto/CryptoPrimes.h b/src/jsc/bindings/node/crypto/CryptoPrimes.h index fa180abd94f4..a60e34780189 100644 --- a/src/jsc/bindings/node/crypto/CryptoPrimes.h +++ b/src/jsc/bindings/node/crypto/CryptoPrimes.h @@ -25,10 +25,7 @@ struct CheckPrimeJobCtx { // Opaque struct created zig land struct CheckPrimeJob { - static CheckPrimeJob* create(JSC::JSGlobalObject*, ncrypto::BignumPointer candidate, int32_t checks, JSC::JSValue callback); static void createAndSchedule(JSC::JSGlobalObject* globalObject, ncrypto::BignumPointer candidate, int32_t checks, JSC::JSValue callback); - - void schedule(); }; struct GeneratePrimeJobCtx { @@ -51,12 +48,9 @@ struct GeneratePrimeJobCtx { // Opaque struct created zig land struct GeneratePrimeJob { - static GeneratePrimeJob* create(JSC::JSGlobalObject*, int32_t size, bool safe, ncrypto::BignumPointer prime, ncrypto::BignumPointer add, ncrypto::BignumPointer rem, bool bigint, JSC::JSValue callback); static void createAndSchedule(JSC::JSGlobalObject*, int32_t size, bool safe, ncrypto::BignumPointer prime, ncrypto::BignumPointer add, ncrypto::BignumPointer rem, bool bigint, JSC::JSValue callback); static JSC::JSValue result(JSC::JSGlobalObject*, JSC::ThrowScope&, const ncrypto::BignumPointer& prime, bool bigint); - - void schedule(); }; JSC_DECLARE_HOST_FUNCTION(jsCheckPrime); diff --git a/src/jsc/bindings/node/crypto/CryptoSignJob.cpp b/src/jsc/bindings/node/crypto/CryptoSignJob.cpp index d9e8aa5259cf..073b664e9a0d 100644 --- a/src/jsc/bindings/node/crypto/CryptoSignJob.cpp +++ b/src/jsc/bindings/node/crypto/CryptoSignJob.cpp @@ -258,19 +258,6 @@ JSCallbackArgs SignJobCtx::runFromJS(JSGlobalObject* lexicalGlobalObject) return {}; } -extern "C" SignJob* Bun__SignJob__create(JSGlobalObject* globalObject, SignJobCtx* ctx, EncodedJSValue callback); -SignJob* SignJob::create(JSGlobalObject* globalObject, SignJobCtx&& ctx, JSValue callback) -{ - SignJobCtx* ctxCopy = new SignJobCtx(WTF::move(ctx)); - return Bun__SignJob__create(globalObject, ctxCopy, JSValue::encode(callback)); -} - -extern "C" void Bun__SignJob__schedule(SignJob* job); -void SignJob::schedule() -{ - Bun__SignJob__schedule(this); -} - extern "C" void Bun__SignJob__createAndSchedule(JSGlobalObject* globalObject, SignJobCtx* ctx, EncodedJSValue callback); void SignJob::createAndSchedule(JSGlobalObject* globalObject, SignJobCtx&& ctx, JSValue callback) { diff --git a/src/jsc/bindings/node/crypto/CryptoSignJob.h b/src/jsc/bindings/node/crypto/CryptoSignJob.h index 694ea27b19a0..ca6d869f2a0e 100644 --- a/src/jsc/bindings/node/crypto/CryptoSignJob.h +++ b/src/jsc/bindings/node/crypto/CryptoSignJob.h @@ -72,8 +72,6 @@ struct SignJobCtx { }; struct SignJob { - static SignJob* create(JSC::JSGlobalObject*, SignJobCtx&&, JSC::JSValue callback); static void createAndSchedule(JSC::JSGlobalObject*, SignJobCtx&&, JSC::JSValue callback); - void schedule(); }; } diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index 9ebe30f869da..113c5030a594 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -209,13 +209,17 @@ class VersionSqlite3 { WTF_DEPRECATED_MAKE_FAST_ALLOCATED_WITH_HEAP_IDENTIFIER(VersionSqlite3, VersionSqlite3); public: - explicit VersionSqlite3(sqlite3* db) + explicit VersionSqlite3(sqlite3* db, JSC::VM* vm) : db(db) + , vm(vm) , version(0) , reference_count(1) { } sqlite3* db; + // The VM (main thread or worker) that opened this connection; only that VM's exit closes it + // (Bun__closeAllSQLiteDatabasesForTermination). + JSC::VM* const vm; std::atomic version; size_t reference_count; WTF::HashSet statements; @@ -293,15 +297,20 @@ extern "C" void Bun__sqliteCheckpointForTermination(sqlite3* db) sqlite3_wal_checkpoint_v2(db, nullptr, SQLITE_CHECKPOINT_TRUNCATE, nullptr, nullptr); } -extern "C" void Bun__closeAllSQLiteDatabasesForTermination() +// Called from the exiting VM's teardown once script is forbidden and its child workers are +// joined: closes the connections that VM opened and never touches another VM's entries. +extern "C" void Bun__closeAllSQLiteDatabasesForTermination(JSC::JSGlobalObject* globalObject) { if (!_instance) { return; } + JSC::VM* exitingVM = &globalObject->vm(); WTF::Locker locker { databasesLock }; auto& dbs = _instance->databases; for (auto& db : dbs) { + if (db->vm != exitingVM) + continue; if (db->db) { Bun__sqliteCheckpointForTermination(db->db); // close_v2: with unfinalized statements still alive, plain @@ -1333,7 +1342,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementDeserialize, (JSC::JSGlobalObject * lexic return {}; } - auto count = registerDatabase(new VersionSqlite3(db)); + auto count = registerDatabase(new VersionSqlite3(db, &vm)); RELEASE_AND_RETURN(scope, JSValue::encode(jsNumber(count))); } @@ -1806,7 +1815,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementOpenStatementFunction, (JSC::JSGlobalObje if (status != SQLITE_OK) { // TODO: log a warning here that defensive mode is unsupported. } - auto* versionDB = new VersionSqlite3(db); + auto* versionDB = new VersionSqlite3(db, &vm); auto index = registerDatabase(versionDB); if (finalizationTarget.isObject()) { vm.heap.addFinalizer(finalizationTarget.getObject(), [versionDB](JSC::JSCell* ptr) -> void { diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index b0452b23e2b4..fe4236da5028 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -961,17 +961,17 @@ void JSDatabaseSync::finishDeferredClose() m_registeredCallbacks.clear(); } -// Called from ExitHandler::dispatch_on_exit, on the main thread only; entries -// owned by another VM (a worker) are skipped by the stored-VM comparison -// without ever touching the foreign cell. +// Called from the exiting VM's teardown once script is forbidden and its child workers are +// joined: closes that VM's entries; others are skipped by the stored-VM comparison without +// touching the foreign cell. extern "C" void Bun__closeAllNodeSqliteDatabasesForTermination(JSC::JSGlobalObject* globalObject) { - JSC::VM* mainVM = &globalObject->vm(); + JSC::VM* exitingVM = &globalObject->vm(); WTF::Vector toClose; { WTF::Locker locker { openDatabasesLock }; for (auto& entry : openDatabases()) { - if (entry.value == mainVM) + if (entry.value == exitingVM) toClose.append(entry.key); } } diff --git a/src/jsc/bindings/webcore/AbortAlgorithm.h b/src/jsc/bindings/webcore/AbortAlgorithm.h index 85cba42b7f42..eaa6da00280c 100644 --- a/src/jsc/bindings/webcore/AbortAlgorithm.h +++ b/src/jsc/bindings/webcore/AbortAlgorithm.h @@ -35,6 +35,10 @@ class AbortAlgorithm : public ThreadSafeRefCounted, public Activ public: using ActiveDOMCallback::ActiveDOMCallback; + // ActiveDOMCallback. + void ref() const final { ThreadSafeRefCounted::ref(); } + void deref() const final { ThreadSafeRefCounted::deref(); } + virtual CallbackResult handleEvent(JSC::JSValue) = 0; }; diff --git a/src/jsc/bindings/webcore/AbortSignal.cpp b/src/jsc/bindings/webcore/AbortSignal.cpp index f9618614003c..5ebd0d4aedde 100644 --- a/src/jsc/bindings/webcore/AbortSignal.cpp +++ b/src/jsc/bindings/webcore/AbortSignal.cpp @@ -116,7 +116,7 @@ AbortSignal::~AbortSignal() // on the freed vector. Clearing only the impl's object pointer leaves // the impl itself (and the EventTargetData it hosts) intact so // ~EventTarget()'s eventTargetData() lookup still works. - if (auto* impl = weakPtrFactory().impl()) + if (auto* impl = EventTargetWithInlineData::weakPtrFactory().impl()) impl->clear(); cancelTimer(); diff --git a/src/jsc/bindings/webcore/AbortSignal.h b/src/jsc/bindings/webcore/AbortSignal.h index ef1a5b9e313b..1664495dc96f 100644 --- a/src/jsc/bindings/webcore/AbortSignal.h +++ b/src/jsc/bindings/webcore/AbortSignal.h @@ -105,8 +105,10 @@ class AbortSignal final : public RefCounted, public EventTargetWith bool hasAbortEventListener() const { return m_flags & static_cast(AbortSignalFlags::HasAbortEventListener); } bool isFiringEventListeners() const { return m_flags & static_cast(AbortSignalFlags::IsFiringEventListeners); } - using RefCounted::deref; - using RefCounted::ref; + // ContextDestructionObserver. + void ref() const final { RefCounted::ref(); } + void deref() const final { RefCounted::deref(); } + USING_CAN_MAKE_WEAKPTR(EventTargetWithInlineData); using Algorithm = Function; uint32_t addAlgorithm(Algorithm&&); diff --git a/src/jsc/bindings/webcore/ActiveDOMObject.cpp b/src/jsc/bindings/webcore/ActiveDOMObject.cpp new file mode 100644 index 000000000000..c92dd9c867ee --- /dev/null +++ b/src/jsc/bindings/webcore/ActiveDOMObject.cpp @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2008 Apple Inc. All Rights Reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + */ + +#include "config.h" +#include "ActiveDOMObject.h" + +#include "Event.h" +#include "EventTarget.h" +#include "ScriptExecutionContext.h" +#include + +namespace WebCore { + +ActiveDOMObject::ActiveDOMObject(ScriptExecutionContext* context) + : ContextDestructionObserver(context) +{ + if (!context) + return; + + ASSERT(context->isContextThread()); + context->didCreateActiveDOMObject(*this); +} + +ActiveDOMObject::~ActiveDOMObject() +{ + ASSERT(canCurrentThreadIDAccessThreadLocalData(m_creationThreadID)); + + // ActiveDOMObject may be inherited by a sub-class whose life-cycle + // exceeds that of the associated ScriptExecutionContext. In those cases, + // m_scriptExecutionContext would/should have been nullified by + // ContextDestructionObserver::contextDestroyed() (which we implement / + // inherit). Hence, we should ensure that this is not 0 before use it + // here. + RefPtr context = scriptExecutionContext(); + if (!context) + return; + + ASSERT(m_suspendIfNeededWasCalled); + ASSERT(context->isContextThread()); + context->willDestroyActiveDOMObject(*this); +} + +void ActiveDOMObject::suspendIfNeeded() +{ +#if ASSERT_ENABLED + ASSERT(!m_suspendIfNeededWasCalled); + m_suspendIfNeededWasCalled = true; +#endif + if (RefPtr context = scriptExecutionContext()) + context->suspendActiveDOMObjectIfNeeded(*this); +} + +#if ASSERT_ENABLED + +void ActiveDOMObject::assertSuspendIfNeededWasCalled() const +{ + ASSERT(m_suspendIfNeededWasCalled); +} + +#endif // ASSERT_ENABLED + +void ActiveDOMObject::stop() +{ +} + +bool ActiveDOMObject::isContextStopped() const +{ + return !scriptExecutionContext() || scriptExecutionContext()->activeDOMObjectsAreStopped(); +} + +bool ActiveDOMObject::isAllowedToRunScript() const +{ + return scriptExecutionContext() && !scriptExecutionContext()->activeDOMObjectsAreStopped(); +} + +void ActiveDOMObject::queueTaskInEventLoop(TaskSource, Function&& function) +{ + RefPtr context = scriptExecutionContext(); + if (!context) + return; + context->postTask([function = WTF::move(function)](ScriptExecutionContext&) mutable { + function(); + }); +} + +void ActiveDOMObject::queueTaskToDispatchEventInternal(EventTarget& target, TaskSource, Ref&& event) +{ + ASSERT(!event->target() || &target == event->target()); + RefPtr context = scriptExecutionContext(); + if (!context) + return; + context->postTask([activity = makePendingActivity(*this), target = Ref { target }, event = WTF::move(event)](ScriptExecutionContext&) { + // If this task executes after the script execution context has been stopped, don't + // actually dispatch the event. + if (activity->object().isAllowedToRunScript()) + target->dispatchEvent(event); + }); +} + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/ActiveDOMObject.h b/src/jsc/bindings/webcore/ActiveDOMObject.h new file mode 100644 index 000000000000..3379b6ec16bf --- /dev/null +++ b/src/jsc/bindings/webcore/ActiveDOMObject.h @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2008 Apple Inc. All Rights Reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + */ + +#pragma once + +#include "ContextDestructionObserver.h" +#include "TaskSource.h" +#include +#include +#include +#include +#include +#include + +namespace WebCore { + +class Event; +class EventTarget; + +class WEBCORE_EXPORT ActiveDOMObject : public ContextDestructionObserver { +public: + // Must be called exactly once after construction: an object created on a context whose active + // objects are already stopped is stop()ped right away. + void suspendIfNeeded(); + void assertSuspendIfNeededWasCalled() const; + + // This function is used by JS bindings to determine if the JS wrapper should be kept alive or not. + // Also read on the GC thread (JS*Owner::isReachableFromOpaqueRoots), concurrently with the mutator. + bool hasPendingActivity() const { return m_pendingActivityInstanceCount.load(std::memory_order_relaxed) || virtualHasPendingActivity(); } + + // This function must not have a side effect of creating an ActiveDOMObject. + // That means it must not result in calls to arbitrary JavaScript. + // It can, however, have a side effect of deleting an ActiveDOMObject. + virtual void stop(); + + template + class PendingActivity : public RefCounted> { + public: + explicit PendingActivity(T& thisObject) + : m_thisObject(thisObject) + { + m_thisObject->m_pendingActivityInstanceCount.fetch_add(1, std::memory_order_relaxed); + } + + ~PendingActivity() + { + ASSERT(m_thisObject->m_pendingActivityInstanceCount.load(std::memory_order_relaxed) > 0); + m_thisObject->m_pendingActivityInstanceCount.fetch_sub(1, std::memory_order_relaxed); + } + + T& object() { return m_thisObject.get(); } + + private: + const Ref m_thisObject; + }; + + template Ref> makePendingActivity(T& thisObject) + { + ASSERT(&thisObject == this); + return adoptRef(*new PendingActivity(thisObject)); + } + + bool isContextStopped() const; + bool isAllowedToRunScript() const; + + template + static void queueTaskKeepingObjectAlive(T& object, TaskSource source, Task&& task) + { + auto activity = object.ActiveDOMObject::makePendingActivity(object); + object.queueTaskInEventLoop(source, [protectedObject = Ref { object }, activity = WTF::move(activity), task = WTF::move(task)]() mutable { + task(protectedObject.get()); + }); + } + + template + static void queueTaskToDispatchEvent(EventTargetType& target, TaskSource source, Ref&& event) + { + target.queueTaskToDispatchEventInternal(target, source, WTF::move(event)); + } + +protected: + explicit ActiveDOMObject(ScriptExecutionContext*); + virtual ~ActiveDOMObject(); + +private: + // This is used by subclasses to indicate that they have pending activity, meaning that they would + // like the JS wrapper to stay alive (because they may still fire JS events). + virtual bool virtualHasPendingActivity() const { return false; } + + void queueTaskInEventLoop(TaskSource, Function&&); + void queueTaskToDispatchEventInternal(EventTarget&, TaskSource, Ref&&); + + std::atomic m_pendingActivityInstanceCount { 0 }; +#if ASSERT_ENABLED + bool m_suspendIfNeededWasCalled { false }; + const uint32_t m_creationThreadID { currentThreadID() }; +#endif +}; + +#if !ASSERT_ENABLED + +inline void ActiveDOMObject::assertSuspendIfNeededWasCalled() const +{ +} + +#endif + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/BroadcastChannel.cpp b/src/jsc/bindings/webcore/BroadcastChannel.cpp index c88bb970df10..554bed3e35c3 100644 --- a/src/jsc/bindings/webcore/BroadcastChannel.cpp +++ b/src/jsc/bindings/webcore/BroadcastChannel.cpp @@ -33,18 +33,16 @@ #include "SerializedScriptValue.h" #include -extern "C" void Bun__eventLoop__incrementRefConcurrently(void* bunVM, int delta); - namespace WebCore { WTF_MAKE_TZONE_ALLOCATED_IMPL(BroadcastChannel); BroadcastChannel::BroadcastChannel(ScriptExecutionContext& context, const String& name) - : ContextDestructionObserver(&context) + : ActiveDOMObject(&context) , m_name(name.isolatedCopy()) , m_contextId(context.identifier()) { - initializeWeakPtrFactory(); + EventTarget::initializeWeakPtrFactory(); BunBroadcastChannelRegistry::singleton().subscribe(m_name, m_contextId, *this); jsRef(context.jsGlobalObject()); } @@ -114,7 +112,7 @@ void BroadcastChannel::eventListenersDidChange() m_state.fetch_and(~uint64_t(HasMessageListener), std::memory_order_acq_rel); } -bool BroadcastChannel::hasPendingActivity() const +bool BroadcastChannel::virtualHasPendingActivity() const { // Called from the GC thread; a single atomic load covers everything. // Queued-but-undelivered messages are NOT counted as pending activity: @@ -131,7 +129,7 @@ void BroadcastChannel::jsRef(JSGlobalObject* lexicalGlobalObject) { if (!m_hasRef) { m_hasRef = true; - Bun__eventLoop__incrementRefConcurrently(WebCore::clientData(lexicalGlobalObject->vm())->bunVM, 1); + Bun__eventLoop__refKeepAlive(WebCore::clientData(lexicalGlobalObject->vm())->bunVM, 1); } } @@ -139,7 +137,7 @@ void BroadcastChannel::jsUnref(JSGlobalObject* lexicalGlobalObject) { if (m_hasRef) { m_hasRef = false; - Bun__eventLoop__incrementRefConcurrently(WebCore::clientData(lexicalGlobalObject->vm())->bunVM, -1); + Bun__eventLoop__refKeepAlive(WebCore::clientData(lexicalGlobalObject->vm())->bunVM, -1); } } diff --git a/src/jsc/bindings/webcore/BroadcastChannel.h b/src/jsc/bindings/webcore/BroadcastChannel.h index bbaf55e72f89..c2e7ab8df833 100644 --- a/src/jsc/bindings/webcore/BroadcastChannel.h +++ b/src/jsc/bindings/webcore/BroadcastChannel.h @@ -36,7 +36,7 @@ #pragma once -#include "ContextDestructionObserver.h" +#include "ActiveDOMObject.h" #include "EventTarget.h" #include "ExceptionOr.h" #include "ScriptExecutionContext.h" @@ -52,18 +52,22 @@ namespace WebCore { class SerializedScriptValue; -class BroadcastChannel final : public ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr, public EventTarget, public ContextDestructionObserver { +class BroadcastChannel final : public ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr, public EventTarget, public ActiveDOMObject { WTF_MAKE_TZONE_ALLOCATED(BroadcastChannel); public: static Ref create(ScriptExecutionContext& context, const String& name) { - return adoptRef(*new BroadcastChannel(context, name)); + auto channel = adoptRef(*new BroadcastChannel(context, name)); + channel->suspendIfNeeded(); + return channel; } ~BroadcastChannel(); - using ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr::ref; - using ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr::deref; + // ActiveDOMObject. + void ref() const final { ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr::ref(); } + void deref() const final { ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr::deref(); } + USING_CAN_MAKE_WEAKPTR(EventTarget); String name() const { return m_name; } @@ -74,8 +78,6 @@ class BroadcastChannel final : public ThreadSafeRefCountedAndCanMakeThreadSafeWe // Called on this channel's context thread with one message. void dispatchMessage(Ref&&); - bool hasPendingActivity() const; - void jsRef(JSGlobalObject*); void jsUnref(JSGlobalObject*); @@ -86,11 +88,15 @@ class BroadcastChannel final : public ThreadSafeRefCountedAndCanMakeThreadSafeWe // EventTarget EventTargetInterface eventTargetInterface() const final { return BroadcastChannelEventTargetInterfaceType; } - ScriptExecutionContext* scriptExecutionContext() const final { return ContextDestructionObserver::scriptExecutionContext(); } + ScriptExecutionContext* scriptExecutionContext() const final { return ActiveDOMObject::scriptExecutionContext(); } void refEventTarget() final { ref(); } void derefEventTarget() final { deref(); } void eventListenersDidChange() final; + + // ActiveDOMObject. void contextDestroyed() final; + bool virtualHasPendingActivity() const final; + void stop() final { close(); } // State is a single atomic so the GC-thread hasPendingActivity() check // never takes a lock. diff --git a/src/jsc/bindings/webcore/ContextDestructionObserver.h b/src/jsc/bindings/webcore/ContextDestructionObserver.h index c90a25bd0adc..0958c697da91 100644 --- a/src/jsc/bindings/webcore/ContextDestructionObserver.h +++ b/src/jsc/bindings/webcore/ContextDestructionObserver.h @@ -4,10 +4,11 @@ #include "root.h" #include "ScriptExecutionContext.h" +#include namespace WebCore { -class ContextDestructionObserver { +class ContextDestructionObserver : public AbstractRefCountedAndCanMakeWeakPtr { public: WEBCORE_EXPORT virtual void contextDestroyed(); @@ -16,7 +17,7 @@ class ContextDestructionObserver { RefPtr protectedScriptExecutionContext() const; protected: - WEBCORE_EXPORT ContextDestructionObserver(ScriptExecutionContext*); + WEBCORE_EXPORT explicit ContextDestructionObserver(ScriptExecutionContext*); WEBCORE_EXPORT virtual ~ContextDestructionObserver(); void observeContext(ScriptExecutionContext*); diff --git a/src/jsc/bindings/webcore/EventEmitter.h b/src/jsc/bindings/webcore/EventEmitter.h index 9dc8be6a6727..26e66b2e12c6 100644 --- a/src/jsc/bindings/webcore/EventEmitter.h +++ b/src/jsc/bindings/webcore/EventEmitter.h @@ -39,8 +39,10 @@ class EventEmitter final : public ScriptWrappable, public CanMakeWeakPtr create(ScriptExecutionContext&); WEBCORE_EXPORT ~EventEmitter() = default; - using RefCounted::deref; - using RefCounted::ref; + // ContextDestructionObserver. + void ref() const final { RefCounted::ref(); } + void deref() const final { RefCounted::deref(); } + USING_CAN_MAKE_WEAKPTR(CanMakeWeakPtr); ScriptExecutionContext* scriptExecutionContext() const { return ContextDestructionObserver::scriptExecutionContext(); }; diff --git a/src/jsc/bindings/webcore/EventTarget.cpp b/src/jsc/bindings/webcore/EventTarget.cpp index 96be8f73f7d6..70ade8b81fc2 100644 --- a/src/jsc/bindings/webcore/EventTarget.cpp +++ b/src/jsc/bindings/webcore/EventTarget.cpp @@ -79,11 +79,6 @@ bool EventTarget::isNode() const return false; } -bool EventTarget::isContextStopped() const -{ - return !scriptExecutionContext(); -} - bool EventTarget::addEventListener(const AtomString& eventType, Ref&& listener, const AddEventListenerOptions& options) { #if ASSERT_ENABLED @@ -196,7 +191,7 @@ JSEventListener* EventTarget::attributeEventListener(const AtomString& eventType continue; auto& jsListener = downcast(listener); - if (jsListener.isAttribute() && &jsListener.isolatedWorld() == &isolatedWorld) + if (jsListener.isAttribute() && jsListener.isolatedWorld() == &isolatedWorld) return &jsListener; } diff --git a/src/jsc/bindings/webcore/EventTarget.h b/src/jsc/bindings/webcore/EventTarget.h index b8b861721e4e..2f7ff281ef8b 100644 --- a/src/jsc/bindings/webcore/EventTarget.h +++ b/src/jsc/bindings/webcore/EventTarget.h @@ -101,8 +101,6 @@ class EventTarget : public ScriptWrappable, public CanMakeWeakPtrWithBitField; WEBCORE_EXPORT void addEventListenerForBindings(const AtomString& eventType, RefPtr&&, AddEventListenerOptionsOrBoolean&&); using EventListenerOptionsOrBoolean = std::variant; diff --git a/src/jsc/bindings/webcore/EventTargetConcrete.h b/src/jsc/bindings/webcore/EventTargetConcrete.h index 8c6181cd0505..0bedef13de12 100644 --- a/src/jsc/bindings/webcore/EventTargetConcrete.h +++ b/src/jsc/bindings/webcore/EventTargetConcrete.h @@ -40,8 +40,10 @@ class EventTargetConcrete final : public RefCounted, public public: static Ref create(ScriptExecutionContext&); - using RefCounted::deref; - using RefCounted::ref; + // ContextDestructionObserver. + void ref() const final { RefCounted::ref(); } + void deref() const final { RefCounted::deref(); } + USING_CAN_MAKE_WEAKPTR(EventTargetWithInlineData); private: explicit EventTargetConcrete(ScriptExecutionContext&); diff --git a/src/jsc/bindings/webcore/EventTargetHeaders.h b/src/jsc/bindings/webcore/EventTargetHeaders.h index dda35f971db7..a242b120c531 100644 --- a/src/jsc/bindings/webcore/EventTargetHeaders.h +++ b/src/jsc/bindings/webcore/EventTargetHeaders.h @@ -40,6 +40,6 @@ #include "Worker.h" #include "JSWorker.h" -#include "BunWorkerGlobalScope.h" +#include "GlobalEventScope.h" #endif // EventTargetHeaders_h diff --git a/src/jsc/bindings/webcore/JSBroadcastChannel.cpp b/src/jsc/bindings/webcore/JSBroadcastChannel.cpp index 3f42cf5322b0..ed823a27192e 100644 --- a/src/jsc/bindings/webcore/JSBroadcastChannel.cpp +++ b/src/jsc/bindings/webcore/JSBroadcastChannel.cpp @@ -21,6 +21,8 @@ #include "config.h" #include "JSBroadcastChannel.h" +#include "ActiveDOMObject.h" + #include "EventNames.h" #include "ExtendedDOMClientIsoSubspaces.h" #include "ExtendedDOMIsoSubspaces.h" @@ -247,7 +249,7 @@ JSBroadcastChannel::JSBroadcastChannel(Structure* structure, JSDOMGlobalObject& { } -// static_assert(std::is_base_of::value, "Interface is marked as [ActiveDOMObject] but implementation class does not subclass ActiveDOMObject."); +static_assert(std::is_base_of::value, "Interface is marked as [ActiveDOMObject] but implementation class does not subclass ActiveDOMObject."); JSObject* JSBroadcastChannel::createPrototype(VM& vm, JSDOMGlobalObject& globalObject) { diff --git a/src/jsc/bindings/webcore/JSDOMGuardedObject.h b/src/jsc/bindings/webcore/JSDOMGuardedObject.h index 09d56bb3f02e..4ceeffc08467 100644 --- a/src/jsc/bindings/webcore/JSDOMGuardedObject.h +++ b/src/jsc/bindings/webcore/JSDOMGuardedObject.h @@ -36,6 +36,10 @@ namespace WebCore { class WEBCORE_EXPORT DOMGuardedObject : public RefCounted, public ActiveDOMCallback { public: + // ActiveDOMCallback. + void ref() const final { RefCounted::ref(); } + void deref() const final { RefCounted::deref(); } + ~DOMGuardedObject(); bool isSuspended() const { return !m_guarded || !canInvokeCallback(); } // The wrapper world has gone away or active DOM objects have been suspended. diff --git a/src/jsc/bindings/webcore/JSErrorHandler.cpp b/src/jsc/bindings/webcore/JSErrorHandler.cpp index 5c79d2ac7366..e7571a185aae 100644 --- a/src/jsc/bindings/webcore/JSErrorHandler.cpp +++ b/src/jsc/bindings/webcore/JSErrorHandler.cpp @@ -68,7 +68,11 @@ void JSErrorHandler::handleEvent(ScriptExecutionContext& scriptExecutionContext, if (!jsFunction) return; - auto* globalObject = toJSDOMGlobalObject(scriptExecutionContext, isolatedWorld()); + auto* isolatedWorld = this->isolatedWorld(); + if (!isolatedWorld) [[unlikely]] + return; + + auto* globalObject = toJSDOMGlobalObject(scriptExecutionContext, *isolatedWorld); if (!globalObject) return; diff --git a/src/jsc/bindings/webcore/JSEventListener.cpp b/src/jsc/bindings/webcore/JSEventListener.cpp index 9cf8397929f6..84f110ec3ab5 100644 --- a/src/jsc/bindings/webcore/JSEventListener.cpp +++ b/src/jsc/bindings/webcore/JSEventListener.cpp @@ -49,16 +49,38 @@ JSEventListener::JSEventListener(JSObject* function, JSObject* wrapper, bool isA , m_wasCreatedFromMarkup(createdFromMarkup == CreatedFromMarkup::Yes) , m_isInitialized(false) , m_wrapper(wrapper) - , m_isolatedWorld(isolatedWorld) + , m_isolatedWorld(&isolatedWorld) { if (function) { ASSERT(wrapper); m_jsFunction = JSC::Weak(function); m_isInitialized = true; } + auto* clientData = WebCore::clientData(isolatedWorld.vm()); + if (clientData->isWorkerVM()) + clientData->addClient(*this); } -JSEventListener::~JSEventListener() = default; +JSEventListener::~JSEventListener() +{ + // Still linked ⇒ the VM is alive (its teardown unlinks before invalidating). + if (isOnList()) + remove(); +} + +void JSEventListener::invalidate() +{ + m_jsFunction.clear(); + m_wrapper.clear(); + m_isInitialized = false; + m_isolatedWorld = nullptr; +} + +// ~JSVMClientData, after unlinking: the heap these handles point into is going. +void JSEventListener::willDestroyVM() +{ + invalidate(); +} Ref JSEventListener::create(JSC::JSObject& listener, JSC::JSObject& wrapper, bool isAttribute, DOMWrapperWorld& world) { @@ -144,7 +166,10 @@ void JSEventListener::handleEvent(ScriptExecutionContext& scriptExecutionContext if (!jsFunction) return; - JSDOMGlobalObject* globalObject = toJSDOMGlobalObject(scriptExecutionContext, m_isolatedWorld); + if (!m_isolatedWorld) [[unlikely]] + return; + + JSDOMGlobalObject* globalObject = toJSDOMGlobalObject(scriptExecutionContext, *m_isolatedWorld); if (!globalObject) return; @@ -255,7 +280,7 @@ bool JSEventListener::operator==(const EventListener& listener) const String JSEventListener::functionName() const { - if (!m_wrapper || !m_jsFunction) + if (!m_wrapper || !m_jsFunction || !m_isolatedWorld) return {}; auto& vm = m_isolatedWorld->vm(); diff --git a/src/jsc/bindings/webcore/JSEventListener.h b/src/jsc/bindings/webcore/JSEventListener.h index d98def60c5b3..a9ea8523c496 100644 --- a/src/jsc/bindings/webcore/JSEventListener.h +++ b/src/jsc/bindings/webcore/JSEventListener.h @@ -20,6 +20,7 @@ #pragma once // #include "DOMWindow.h" +#include "BunClientData.h" #include "DOMWrapperWorld.h" #include "EventListener.h" #include "EventNames.h" @@ -34,7 +35,8 @@ namespace WebCore { -class JSEventListener : public EventListener { +class JSEventListener : public EventListener, public JSVMClientDataClient { + WTF_DEPRECATED_MAKE_FAST_ALLOCATED(JSEventListener); // both bases declare allocators public: WEBCORE_EXPORT static Ref create(JSC::JSObject& listener, JSC::JSObject& wrapper, bool isAttribute, DOMWrapperWorld&); @@ -48,7 +50,7 @@ class JSEventListener : public EventListener { bool wasCreatedFromMarkup() const { return m_wasCreatedFromMarkup; } JSC::JSObject* ensureJSFunction(ScriptExecutionContext&) const; - DOMWrapperWorld& isolatedWorld() const { return m_isolatedWorld; } + DOMWrapperWorld* isolatedWorld() const { return m_isolatedWorld.get(); } JSC::JSObject* jsFunction() const final { return m_jsFunction.get(); } JSC::JSObject* wrapper() const final { return m_wrapper.get(); } @@ -80,7 +82,12 @@ class JSEventListener : public EventListener { void handleEvent(ScriptExecutionContext&, Event&) override; void setWrapperWhenInitializingJSFunction(JSC::VM&, JSC::JSObject* wrapper) const { m_wrapper = JSC::Weak(wrapper); } + // JSVMClientDataClient + void willDestroyVM() final; + private: + void invalidate(); + bool m_isAttribute : 1; bool m_wasCreatedFromMarkup : 1; @@ -88,7 +95,7 @@ class JSEventListener : public EventListener { mutable JSC::Weak m_jsFunction; mutable JSC::Weak m_wrapper; - Ref m_isolatedWorld; + RefPtr m_isolatedWorld; }; // For "onxxx" attributes that automatically set up JavaScript event listeners. @@ -104,6 +111,9 @@ inline JSC::JSObject* JSEventListener::ensureJSFunction(ScriptExecutionContext& { // initializeJSFunction can trigger code that deletes this event listener // before we're done. It should always return null in this case. + if (!m_isolatedWorld) [[unlikely]] + return nullptr; + JSC::VM& vm = m_isolatedWorld->vm(); Ref protect = const_cast(*this); JSC::EnsureStillAliveScope protectedWrapper(m_wrapper.get()); diff --git a/src/jsc/bindings/webcore/JSEventTargetCustom.cpp b/src/jsc/bindings/webcore/JSEventTargetCustom.cpp index 5cc9953b067a..3f5c855680d3 100644 --- a/src/jsc/bindings/webcore/JSEventTargetCustom.cpp +++ b/src/jsc/bindings/webcore/JSEventTargetCustom.cpp @@ -31,7 +31,7 @@ #include "EventTargetInterfaces.h" #include "JSDOMWrapperCache.h" #include "JSEventListener.h" -#include "BunWorkerGlobalScope.h" +#include "GlobalEventScope.h" #if ENABLE(OFFSCREEN_CANVAS) #include "OffscreenCanvas.h" diff --git a/src/jsc/bindings/webcore/JSMessagePort.cpp b/src/jsc/bindings/webcore/JSMessagePort.cpp index 2b6803f15089..3b70232ef476 100644 --- a/src/jsc/bindings/webcore/JSMessagePort.cpp +++ b/src/jsc/bindings/webcore/JSMessagePort.cpp @@ -21,6 +21,8 @@ #include "config.h" #include "JSMessagePort.h" +#include "ActiveDOMObject.h" + #include "EventNames.h" #include "ExtendedDOMClientIsoSubspaces.h" #include "ExtendedDOMIsoSubspaces.h" @@ -153,7 +155,7 @@ JSMessagePort::JSMessagePort(Structure* structure, JSDOMGlobalObject& globalObje { } -// static_assert(std::is_base_of::value, "Interface is marked as [ActiveDOMObject] but implementation class does not subclass ActiveDOMObject."); +static_assert(std::is_base_of::value, "Interface is marked as [ActiveDOMObject] but implementation class does not subclass ActiveDOMObject."); JSObject* JSMessagePort::createPrototype(VM& vm, JSDOMGlobalObject& globalObject) { @@ -201,7 +203,12 @@ static inline bool setJSMessagePort_onmessageSetter(JSGlobalObject& lexicalGloba vm.writeBarrier(&thisObject, value); ensureStillAliveHere(value); - thisObject.wrapped().jsRef(&lexicalGlobalObject); + // node: a callable handler starts the port and keeps the loop alive; assigning anything else + // clears the handler and lets the loop exit again. + if (value.isCallable()) + thisObject.wrapped().jsRef(&lexicalGlobalObject); + else + thisObject.wrapped().jsUnref(&lexicalGlobalObject); return true; } @@ -230,8 +237,8 @@ static inline bool setJSMessagePort_onmessageerrorSetter(JSGlobalObject& lexical vm.writeBarrier(&thisObject, value); ensureStillAliveHere(value); - thisObject.wrapped().jsRef(&lexicalGlobalObject); - + // node: only a 'message' handler starts the port and keeps the loop alive (setupPortReferencing); + // a 'messageerror' handler alone does neither. return true; } @@ -446,7 +453,7 @@ bool JSMessagePortOwner::isReachableFromOpaqueRoots(JSC::Handle ha { auto* jsMessagePort = uncheckedDowncast(handle.slot()->asCell()); auto& wrapped = jsMessagePort->wrapped(); - if (wrapped.hasPendingActivity()) { + if (!wrapped.isContextStopped() && wrapped.hasPendingActivity()) { if (reason) [[unlikely]] *reason = "ActiveDOMObject with pending activity"_s; return true; diff --git a/src/jsc/bindings/webcore/JSURLSearchParams.cpp b/src/jsc/bindings/webcore/JSURLSearchParams.cpp index 5ca282a071f0..608d67133e42 100644 --- a/src/jsc/bindings/webcore/JSURLSearchParams.cpp +++ b/src/jsc/bindings/webcore/JSURLSearchParams.cpp @@ -589,7 +589,7 @@ static void putIntoObject(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, if constexpr (hasIndex) { obj->putDirectIndex(lexicalGlobalObject, index.value(), array); - throwScope.assertNoException(); // not a proxy. + RETURN_IF_EXCEPTION(throwScope, ); // not a proxy: OOM / termination only } else { obj->putDirect(vm, ident, array); } @@ -604,7 +604,7 @@ static void putIntoObject(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, seenKeys.add(key); if constexpr (hasIndex) { obj->putDirectIndex(lexicalGlobalObject, index.value(), stringValue); - throwScope.assertNoException(); // not a proxy. + RETURN_IF_EXCEPTION(throwScope, ); // not a proxy: OOM / termination only } else { obj->putDirect(vm, ident, stringValue); } diff --git a/src/jsc/bindings/webcore/JSWebSocket.cpp b/src/jsc/bindings/webcore/JSWebSocket.cpp index 6ce5b6633838..f142a722a528 100644 --- a/src/jsc/bindings/webcore/JSWebSocket.cpp +++ b/src/jsc/bindings/webcore/JSWebSocket.cpp @@ -21,6 +21,8 @@ #include "config.h" #include "JSWebSocket.h" +#include "ActiveDOMObject.h" + #include "EventNames.h" #include "ExtendedDOMClientIsoSubspaces.h" #include "ExtendedDOMIsoSubspaces.h" @@ -438,7 +440,7 @@ void JSWebSocket::finishCreation(VM& vm) Base::finishCreation(vm); ASSERT(inherits(info())); - // static_assert(std::is_base_of::value, "Interface is marked as [ActiveDOMObject] but implementation class does not subclass ActiveDOMObject."); + static_assert(std::is_base_of::value, "Interface is marked as [ActiveDOMObject] but implementation class does not subclass ActiveDOMObject."); } JSObject* JSWebSocket::createPrototype(VM& vm, JSDOMGlobalObject& globalObject) @@ -970,7 +972,7 @@ bool JSWebSocketOwner::isReachableFromOpaqueRoots(JSC::Handle hand { auto* jsWebSocket = uncheckedDowncast(handle.slot()->asCell()); auto& wrapped = jsWebSocket->wrapped(); - if (wrapped.hasPendingActivity()) { + if (!wrapped.isContextStopped() && wrapped.hasPendingActivity()) { if (reason) [[unlikely]] *reason = "ActiveDOMObject with pending activity"_s; return true; diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp index 75c01d4f1c35..f9ec2e787741 100644 --- a/src/jsc/bindings/webcore/JSWorker.cpp +++ b/src/jsc/bindings/webcore/JSWorker.cpp @@ -20,6 +20,8 @@ #include "config.h" #include "JSWorker.h" + +#include "ActiveDOMObject.h" #include "BunCPUProfiler.h" #if OS(WINDOWS) #include @@ -421,7 +423,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsWorker_threadIdGetter, (JSGlobalObject * lexicalGloba return JSValue::encode(jsUndefined()); auto& worker = castedThis->wrapped(); - if (worker.wasTerminated()) return JSValue::encode(jsNumber(-1)); + if (worker.hasExited()) return JSValue::encode(jsNumber(-1)); // Main thread starts at 1 // // Note that we cannot use posix thread ids here because we don't know their thread id until the thread starts @@ -464,7 +466,7 @@ JSWorker::JSWorker(Structure* structure, JSDOMGlobalObject& globalObject, Ref::value, "Interface is marked as [ActiveDOMObject] but implementation class does not subclass ActiveDOMObject."); +static_assert(std::is_base_of::value, "Interface is marked as [ActiveDOMObject] but implementation class does not subclass ActiveDOMObject."); JSObject* JSWorker::createPrototype(VM& vm, JSDOMGlobalObject& globalObject) { @@ -689,11 +691,11 @@ JSC_DEFINE_HOST_FUNCTION(jsWorkerPrototypeFunction_unref, (JSGlobalObject * lexi } // Resolve/reject a cross-VM introspection promise on the parent thread. The -// promise lives in Worker::m_pendingCrossVMRequests keyed by reqId; if it was -// already drained by dispatchExit's rejectAllCrossVMRequests, this is a no-op. -static void resolveCrossVMRequest(Worker& worker, uint64_t reqId, ScriptExecutionContext& parentCtx, JSValue value) +// promise lives in WorkerMessagingProxy::m_pendingCrossVMRequests keyed by reqId; if it was +// already drained by workerGlobalScopeDestroyedInternal's rejectAllCrossVMRequests, this is a no-op. +static void resolveCrossVMRequest(WorkerMessagingProxy& proxy, uint64_t reqId, ScriptExecutionContext& parentCtx, JSValue value) { - if (auto handle = worker.takeCrossVMRequest(reqId)) + if (auto handle = proxy.takeCrossVMRequest(reqId)) handle->resolve(parentCtx.globalObject(), parentCtx.vm(), value); } @@ -723,23 +725,22 @@ static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_getHeapSnapshotBody( } // No up-front isOnline() gate: a worker can post to its parent (e.g. from - // a microtask the entry module scheduled, drained inside - // wait_for_promise_with_termination's tick()) while m_state is still - // Pending. postTaskToWorkerGlobalScope queues into m_pendingTasks for - // Pending and returns false only for Closing/Closed, which the !accepted - // reject below handles. If the worker never reaches Running (entry threw, - // failed to load, unsettled TLA), dispatchExit clears m_pendingTasks on - // the parent thread and rejectAllCrossVMRequests() rejects + frees the - // Strong<>. + // a microtask the entry module scheduled while it was still loading) while + // m_state is still Pending. postTaskToWorkerGlobalScope queues into + // m_pendingTasks for Pending and returns false only for Closing/Closed, + // which the !accepted reject below handles. If the worker never reaches + // Running (entry threw or failed to load), workerGlobalScopeDestroyedInternal + // clears m_pendingTasks on the parent thread and rejectAllCrossVMRequests() + // rejects + frees the Strong<>. auto* promise = JSC::JSPromise::create(vm, globalObject->promiseStructure()); // The promise is registered in a parent-side map keyed by reqId; only the id // crosses threads, so the worker thread never touches the parent VM's - // HandleSet. dispatchExit rejects any entries still in the map (worker - // terminated mid-round-trip), so the promise always settles. - uint64_t reqId = worker.registerCrossVMRequest(vm, promise); + // HandleSet. workerGlobalScopeDestroyedInternal rejects any entries still in + // the map (worker terminated mid-round-trip), so the promise always settles. + uint64_t reqId = worker.contextProxy().registerCrossVMRequest(vm, promise); auto parentId = globalObject->scriptExecutionContext()->identifier(); - bool accepted = worker.postTaskToWorkerGlobalScope([reqId, parentId, protectedWorker = Ref { worker }](ScriptExecutionContext& workerCtx) mutable { + bool accepted = worker.contextProxy().postTaskToWorkerGlobalScope([reqId, parentId, protectedProxy = Ref { worker.contextProxy() }](ScriptExecutionContext& workerCtx) mutable { auto& vm = workerCtx.vm(); vm.ensureHeapProfiler(); auto& heapProfiler = *vm.heapProfiler(); @@ -748,13 +749,13 @@ static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_getHeapSnapshotBody( String snapshot = builder.json(); ScriptExecutionContext::postTaskTo(parentId, - [reqId, protectedWorker = WTF::move(protectedWorker), snapshot = snapshot.isolatedCopy()](ScriptExecutionContext& parentCtx) { - resolveCrossVMRequest(protectedWorker.get(), reqId, parentCtx, jsString(parentCtx.vm(), snapshot)); + [reqId, protectedProxy = WTF::move(protectedProxy), snapshot = snapshot.isolatedCopy()](ScriptExecutionContext& parentCtx) { + resolveCrossVMRequest(protectedProxy.get(), reqId, parentCtx, jsString(parentCtx.vm(), snapshot)); }); }); if (!accepted) { // postTaskToWorkerGlobalScope returns false only for Closing/Closed. - worker.takeCrossVMRequest(reqId); + worker.contextProxy().takeCrossVMRequest(reqId); promise->reject(vm, Bun::createError(globalObject, Bun::ErrorCode::ERR_WORKER_NOT_RUNNING, "Worker instance not running"_s)); } return JSValue::encode(promise); @@ -767,14 +768,14 @@ static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_getHeapStatisticsBod auto& worker = castedThis->wrapped(); auto* promise = JSC::JSPromise::create(vm, globalObject->promiseStructure()); - uint64_t reqId = worker.registerCrossVMRequest(vm, promise); + uint64_t reqId = worker.contextProxy().registerCrossVMRequest(vm, promise); auto parentId = globalObject->scriptExecutionContext()->identifier(); - bool accepted = worker.postTaskToWorkerGlobalScope([reqId, parentId, protectedWorker = Ref { worker }](ScriptExecutionContext& workerCtx) mutable { + bool accepted = worker.contextProxy().postTaskToWorkerGlobalScope([reqId, parentId, protectedProxy = Ref { worker.contextProxy() }](ScriptExecutionContext& workerCtx) mutable { auto& wvm = workerCtx.vm(); double heapSize = static_cast(wvm.heap.size()); double capacity = static_cast(wvm.heap.capacity()); double extra = static_cast(wvm.heap.extraMemorySize()); - ScriptExecutionContext::postTaskTo(parentId, [reqId, protectedWorker = WTF::move(protectedWorker), heapSize, capacity, extra](ScriptExecutionContext& parentCtx) { + ScriptExecutionContext::postTaskTo(parentId, [reqId, protectedProxy = WTF::move(protectedProxy), heapSize, capacity, extra](ScriptExecutionContext& parentCtx) { auto& pvm = parentCtx.vm(); auto* go = parentCtx.globalObject(); JSObject* o = constructEmptyObject(go); @@ -795,11 +796,11 @@ static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_getHeapStatisticsBod set("used_global_handles_size"_s, 2208); set("external_memory"_s, extra); set("total_allocated_bytes"_s, heapSize); - resolveCrossVMRequest(protectedWorker.get(), reqId, parentCtx, o); + resolveCrossVMRequest(protectedProxy.get(), reqId, parentCtx, o); }); }); if (!accepted) { - worker.takeCrossVMRequest(reqId); + worker.contextProxy().takeCrossVMRequest(reqId); promise->reject(vm, Bun::createError(globalObject, Bun::ErrorCode::ERR_WORKER_NOT_RUNNING, "Worker instance not running"_s)); } return JSValue::encode(promise); @@ -811,17 +812,17 @@ static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_startCpuProfileInter auto& vm = JSC::getVM(globalObject); auto& worker = castedThis->wrapped(); auto* promise = JSC::JSPromise::create(vm, globalObject->promiseStructure()); - uint64_t reqId = worker.registerCrossVMRequest(vm, promise); + uint64_t reqId = worker.contextProxy().registerCrossVMRequest(vm, promise); auto parentId = globalObject->scriptExecutionContext()->identifier(); - bool accepted = worker.postTaskToWorkerGlobalScope([reqId, parentId, protectedWorker = Ref { worker }](ScriptExecutionContext& workerCtx) mutable { + bool accepted = worker.contextProxy().postTaskToWorkerGlobalScope([reqId, parentId, protectedProxy = Ref { worker.contextProxy() }](ScriptExecutionContext& workerCtx) mutable { if (!Bun::isCPUProfilerRunning()) Bun::startCPUProfiler(workerCtx.vm()); - ScriptExecutionContext::postTaskTo(parentId, [reqId, protectedWorker = WTF::move(protectedWorker)](ScriptExecutionContext& parentCtx) { - resolveCrossVMRequest(protectedWorker.get(), reqId, parentCtx, jsUndefined()); + ScriptExecutionContext::postTaskTo(parentId, [reqId, protectedProxy = WTF::move(protectedProxy)](ScriptExecutionContext& parentCtx) { + resolveCrossVMRequest(protectedProxy.get(), reqId, parentCtx, jsUndefined()); }); }); if (!accepted) { - worker.takeCrossVMRequest(reqId); + worker.contextProxy().takeCrossVMRequest(reqId); promise->reject(vm, Bun::createError(globalObject, Bun::ErrorCode::ERR_WORKER_NOT_RUNNING, "Worker instance not running"_s)); } return JSValue::encode(promise); @@ -835,22 +836,22 @@ static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_stopCpuProfileIntern auto& vm = JSC::getVM(globalObject); auto& worker = castedThis->wrapped(); auto* promise = JSC::JSPromise::create(vm, globalObject->promiseStructure()); - uint64_t reqId = worker.registerCrossVMRequest(vm, promise); + uint64_t reqId = worker.contextProxy().registerCrossVMRequest(vm, promise); auto parentId = globalObject->scriptExecutionContext()->identifier(); - bool accepted = worker.postTaskToWorkerGlobalScope([reqId, parentId, protectedWorker = Ref { worker }](ScriptExecutionContext& workerCtx) mutable { + bool accepted = worker.contextProxy().postTaskToWorkerGlobalScope([reqId, parentId, protectedProxy = Ref { worker.contextProxy() }](ScriptExecutionContext& workerCtx) mutable { WTF::String result; if (Bun::isCPUProfilerRunning()) Bun::stopCPUProfiler(workerCtx.vm(), &result, nullptr); if (result.isEmpty()) result = kEmptyCpuProfileJSON; - ScriptExecutionContext::postTaskTo(parentId, [reqId, protectedWorker = WTF::move(protectedWorker), result = result.isolatedCopy()](ScriptExecutionContext& parentCtx) { - resolveCrossVMRequest(protectedWorker.get(), reqId, parentCtx, jsString(parentCtx.vm(), result)); + ScriptExecutionContext::postTaskTo(parentId, [reqId, protectedProxy = WTF::move(protectedProxy), result = result.isolatedCopy()](ScriptExecutionContext& parentCtx) { + resolveCrossVMRequest(protectedProxy.get(), reqId, parentCtx, jsString(parentCtx.vm(), result)); }); }); if (!accepted) { // Worker already gone: resolve with an empty profile rather than reject, // so a handle.stop() after terminate still yields parseable JSON. - worker.takeCrossVMRequest(reqId); + worker.contextProxy().takeCrossVMRequest(reqId); promise->resolve(globalObject, vm, jsString(vm, String(kEmptyCpuProfileJSON))); } return JSValue::encode(promise); @@ -862,9 +863,9 @@ static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_cpuUsageInternalBody auto& vm = JSC::getVM(globalObject); auto& worker = castedThis->wrapped(); auto* promise = JSC::JSPromise::create(vm, globalObject->promiseStructure()); - uint64_t reqId = worker.registerCrossVMRequest(vm, promise); + uint64_t reqId = worker.contextProxy().registerCrossVMRequest(vm, promise); auto parentId = globalObject->scriptExecutionContext()->identifier(); - bool accepted = worker.postTaskToWorkerGlobalScope([reqId, parentId, protectedWorker = Ref { worker }](ScriptExecutionContext&) mutable { + bool accepted = worker.contextProxy().postTaskToWorkerGlobalScope([reqId, parentId, protectedProxy = Ref { worker.contextProxy() }](ScriptExecutionContext&) mutable { double user = 0; double sys = 0; #if OS(WINDOWS) @@ -895,17 +896,17 @@ static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_cpuUsageInternalBody user = static_cast(ru.ru_utime.tv_sec) * 1e6 + static_cast(ru.ru_utime.tv_usec); sys = static_cast(ru.ru_stime.tv_sec) * 1e6 + static_cast(ru.ru_stime.tv_usec); #endif - ScriptExecutionContext::postTaskTo(parentId, [reqId, protectedWorker = WTF::move(protectedWorker), user, sys](ScriptExecutionContext& parentCtx) { + ScriptExecutionContext::postTaskTo(parentId, [reqId, protectedProxy = WTF::move(protectedProxy), user, sys](ScriptExecutionContext& parentCtx) { auto& pvm = parentCtx.vm(); auto* go = parentCtx.globalObject(); JSObject* o = constructEmptyObject(go); o->putDirect(pvm, Identifier::fromString(pvm, "user"_s), jsNumber(user)); o->putDirect(pvm, Identifier::fromString(pvm, "system"_s), jsNumber(sys)); - resolveCrossVMRequest(protectedWorker.get(), reqId, parentCtx, o); + resolveCrossVMRequest(protectedProxy.get(), reqId, parentCtx, o); }); }); if (!accepted) { - worker.takeCrossVMRequest(reqId); + worker.contextProxy().takeCrossVMRequest(reqId); promise->reject(vm, Bun::createError(globalObject, Bun::ErrorCode::ERR_WORKER_NOT_RUNNING, "Worker instance not running"_s)); } return JSValue::encode(promise); diff --git a/src/jsc/bindings/webcore/MessagePort.cpp b/src/jsc/bindings/webcore/MessagePort.cpp index a59c83b8c29b..1e1681c17d85 100644 --- a/src/jsc/bindings/webcore/MessagePort.cpp +++ b/src/jsc/bindings/webcore/MessagePort.cpp @@ -31,6 +31,7 @@ #include "BunClientData.h" #include "EventNames.h" +#include "GlobalEventScope.h" #include "JSMessagePort.h" #include "MessageEvent.h" #include "MessagePortPipe.h" @@ -41,24 +42,24 @@ extern "C" void Bun__Process__emitWarning(Zig::GlobalObject*, JSC::EncodedJSValue warning, JSC::EncodedJSValue type, JSC::EncodedJSValue code, JSC::EncodedJSValue ctor); -extern "C" void Bun__eventLoop__incrementRefConcurrently(void* bunVM, int delta); - namespace WebCore { WTF_MAKE_TZONE_ALLOCATED_IMPL(MessagePort); Ref MessagePort::create(ScriptExecutionContext& context, Ref&& pipe, uint8_t side) { - return adoptRef(*new MessagePort(context, WTF::move(pipe), side)); + auto messagePort = adoptRef(*new MessagePort(context, WTF::move(pipe), side)); + messagePort->suspendIfNeeded(); + return messagePort; } MessagePort::MessagePort(ScriptExecutionContext& context, Ref&& pipe, uint8_t side) - : ContextDestructionObserver(&context) + : ActiveDOMObject(&context) , m_pipe(WTF::move(pipe)) , m_side(side) { // The WeakPtrFactory must be initialized on the owning thread. - initializeWeakPtrFactory(); + EventTarget::initializeWeakPtrFactory(); // Any port with a 'message' listener refs the event loop (matching node: a // listening port keeps its thread alive until closed or unref'd); otherwise a // buffered message could be lost if its listener is added late. @@ -144,10 +145,28 @@ ExceptionOr MessagePort::postMessage(JSC::JSGlobalObject& state, JSC::JSVa return {}; } +void MessagePort::entrySettled() +{ + if (!std::exchange(m_startDeferredUntilEntrySettled, false)) + return; + start(); +} + void MessagePort::start() { if (m_started || !isEntangled()) return; + // A node worker's parentPort delivers nothing until the entry module has evaluated; keep the + // request and let entrySettled() perform it. Messages stay buffered in the pipe meanwhile. + if (auto* context = scriptExecutionContext()) { + if (auto* jsGlobal = context->globalObject()) { + auto* globalObject = defaultGlobalObject(jsGlobal); + if (globalObject->nodeParentPort() == this && !globalObject->nodeWorkerEntrySettled()) { + m_startDeferredUntilEntrySettled = true; + return; + } + } + } m_started = true; auto* context = scriptExecutionContext(); @@ -162,20 +181,14 @@ void MessagePort::flushQueuedMessagesBeforeClose() auto* context = scriptExecutionContext(); if (!context || !context->globalObject()) return; - // During worker teardown contextDestroyed() runs from ~ScriptExecutionContext - // inside ~VM's lastChanceToFinalize, where allocating a MessageEvent wrapper - // asserts (the heap is being finalized). markTerminating() precedes ~VM, and - // the Rust-side scriptExecutionStatus still reports Running at that point. - if (context->isTerminating()) - return; auto* globalObject = defaultGlobalObject(context->globalObject()); - // Only deliver while JS can run; during teardown the queue is left for + // Only deliver while JS can run; otherwise the queue is left for // m_pipe->close() to drop (it unwinds nested port chains iteratively). if (Zig::GlobalObject::scriptExecutionStatus(globalObject, globalObject) != ScriptExecutionStatus::Running) return; - // Cap iterations like drainAndDispatch() so a 'message' handler re-injecting - // into this closing port (via its entangled peer) can't starve the loop. + // Cap iterations (whatever was queued, at least 1000) so a 'message' handler + // re-injecting into this closing port (via its entangled peer) can't starve the loop. size_t limit = std::max(MessagePortPipe::queuedCount(m_pipe->state(m_side)), 1000); for (size_t i = 0; i < limit; ++i) { // A handler (or a microtask it queued) may have transferred this port; the @@ -213,11 +226,7 @@ void MessagePort::close() // Release the self-reference taken by jsRef() (set when .onmessage is // assigned or .ref() is called from JS). The JS .close() binding calls - // jsUnref() first, so m_hasRef is already false on that path; we only - // reach this branch when close() runs without a preceding jsUnref() — - // most importantly from contextDestroyed() during Worker teardown. - // Without this, the self-ref pins the MessagePort past the JS wrapper - // sweep and it leaks forever. + // jsUnref() first; stop() and contextDestroyed() do not. if (m_hasRef) { m_hasRef = false; if (auto* context = scriptExecutionContext()) @@ -234,20 +243,15 @@ void MessagePort::close() // Defer 'close' to a task (node fires it at uv close-callback timing, i.e. // after sync code and microtasks), so a listener added after close() still - // observes it and close(cb) interleaves with other listeners. Never while the - // context is terminating: contextDestroyed() runs after the loop's queue was - // drained for shutdown, so the task would never run and would outlive the VM. - auto* context = scriptExecutionContext(); - if (context && !context->isTerminating()) { - m_closeEventPending.store(true, std::memory_order_release); - context->postTask([protectedThis = Ref { *this }](ScriptExecutionContext&) { - protectedThis->dispatchCloseEvent(); - protectedThis->removeAllEventListeners(); - protectedThis->m_closeEventPending.store(false, std::memory_order_release); - }); - } else { + // observes it and close(cb) interleaves with other listeners. + if (isContextStopped()) { removeAllEventListeners(); + return; } + queueTaskKeepingObjectAlive(*this, TaskSource::PostedMessageQueue, [](MessagePort& port) { + port.dispatchCloseEvent(); + port.removeAllEventListeners(); + }); } void MessagePort::dispatchCloseEvent() @@ -258,9 +262,6 @@ void MessagePort::dispatchCloseEvent() auto* context = scriptExecutionContext(); if (!context || !context->globalObject()) return; - // No JS may run during worker teardown (see flushQueuedMessagesBeforeClose). - if (context->isTerminating()) - return; auto* globalObject = defaultGlobalObject(context->globalObject()); // Bypass the m_isDetached guard in MessagePort::dispatchEvent — the deferred // close task runs after m_isDetached is set. @@ -279,7 +280,7 @@ void MessagePort::peerClosed() // Deliver whatever the peer sent before it closed, then fire 'close'. Node orders // them that way, and registerCloseContext()'s retroactive notify can land before any // drain is scheduled -- e.g. on('close') registered before on('message'). - if (m_started && m_hasMessageEventListener) + if (m_started && hasMessageEventListener()) flushQueuedMessagesBeforeClose(); // Fire 'close' (guarded against a double dispatch) and release this side's loop refs // so the loop can idle, matching node. @@ -328,8 +329,11 @@ TransferredMessagePort MessagePort::disentangle() m_isDetached = true; m_started = false; - if (auto* context = scriptExecutionContext()) + // We can't receive any messages or generate any events after this, so remove ourselves from the list of active ports. + if (auto* context = scriptExecutionContext()) { + context->willDestroyActiveDOMObject(*this); context->willDestroyDestructionObserver(*this); + } observeContext(nullptr); return TransferredMessagePort { m_pipe.copyRef(), m_side }; @@ -366,8 +370,9 @@ void MessagePort::dispatchOneMessage(ScriptExecutionContext& context, MessageWit dispatchEvent(event.event); } -JSValue MessagePort::tryTakeMessage(JSGlobalObject* lexicalGlobalObject) +JSValue MessagePort::tryTakeMessage(JSGlobalObject* lexicalGlobalObject, bool& hadMessage) { + hadMessage = false; if (!isEntangled()) return jsUndefined(); @@ -379,6 +384,7 @@ JSValue MessagePort::tryTakeMessage(JSGlobalObject* lexicalGlobalObject) if (!message) return jsUndefined(); + hadMessage = true; auto ports = MessagePort::entanglePorts(*context, WTF::move(message->transferredPorts)); return message->message.releaseNonNull()->deserialize(*lexicalGlobalObject, lexicalGlobalObject, WTF::move(ports), SerializationErrorMode::NonThrowing); } @@ -392,17 +398,13 @@ void MessagePort::dispatchEvent(Event& event) void MessagePort::contextDestroyed() { - // close() releases the jsRef() self-reference, which may be the last - // strong ref if the JS wrapper was already swept. Protect across the - // call so we can cleanly detach from the dying ScriptExecutionContext - // first — otherwise ~ContextDestructionObserver() would call back into - // it while it is mid-destruction. - Ref protectedThis { *this }; + ASSERT(scriptExecutionContext()); + close(); - ContextDestructionObserver::contextDestroyed(); + ActiveDOMObject::contextDestroyed(); } -bool MessagePort::hasPendingActivity() const +bool MessagePort::virtualHasPendingActivity() const { // Called from the GC thread concurrently with the mutator; must be // lockless. m_pipe is a Ref<> held for the port's whole lifetime, so @@ -410,11 +412,6 @@ bool MessagePort::hasPendingActivity() const // atomic loads. The plain bool reads can observe stale values but // cannot crash — at worst the wrapper is collected one cycle early // or late, which is the same tolerance as before this refactor. - // close() sets m_isDetached before queueing the deferred close task, and a port - // with only a 'close' listener has no message listener — so this must precede - // both gates or the wrapper is collected before the task dispatches. - if (m_closeEventPending.load(std::memory_order_acquire)) - return true; if (!scriptExecutionContext() || m_isDetached) return false; // A 'close' listener must outlive a GC until the event lands: notifyPeerClosed() @@ -422,6 +419,9 @@ bool MessagePort::hasPendingActivity() const // the context dies; node retains more — it never collects an entangled port at all. if (m_hasCloseEventListener.load(std::memory_order_acquire) && !m_closeEventDispatched) return true; + // The port's own listeners only: a parentPort delivering to `self.onmessage` (the global-scope + // count hasMessageEventListener() adds) is rooted by the worker global for the worker's life, and + // this GC-thread path stays a single plain-bool read. if (!m_hasMessageEventListener) return false; @@ -572,7 +572,7 @@ void MessagePort::jsRef(JSGlobalObject* lexicalGlobalObject) if (!m_hasRef) { m_hasRef = true; ref(); - Bun__eventLoop__incrementRefConcurrently(WebCore::clientData(lexicalGlobalObject->vm())->bunVM, 1); + Bun__eventLoop__refKeepAlive(WebCore::clientData(lexicalGlobalObject->vm())->bunVM, 1); } } @@ -587,7 +587,7 @@ void MessagePort::jsUnref(JSGlobalObject* lexicalGlobalObject) if (m_hasRef) { m_hasRef = false; deref(); - Bun__eventLoop__incrementRefConcurrently(WebCore::clientData(lexicalGlobalObject->vm())->bunVM, -1); + Bun__eventLoop__refKeepAlive(WebCore::clientData(lexicalGlobalObject->vm())->bunVM, -1); } } diff --git a/src/jsc/bindings/webcore/MessagePort.h b/src/jsc/bindings/webcore/MessagePort.h index 86d047d320b0..c08911798f25 100644 --- a/src/jsc/bindings/webcore/MessagePort.h +++ b/src/jsc/bindings/webcore/MessagePort.h @@ -38,7 +38,7 @@ // of referencing symbols that only exist on this branch's MessagePort. #define BUN_MESSAGEPORT_USES_PIPE 1 -#include "ContextDestructionObserver.h" +#include "ActiveDOMObject.h" #include "EventTarget.h" #include "ExceptionOr.h" #include "MessagePortPipe.h" @@ -59,7 +59,7 @@ struct StructuredSerializeOptions; DECLARE_ALLOCATOR_WITH_HEAP_IDENTIFIER(MessagePort); -class MessagePort final : public ContextDestructionObserver, public EventTarget, public ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr { +class MessagePort final : public ActiveDOMObject, public EventTarget, public ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr { WTF_MAKE_NONCOPYABLE(MessagePort); WTF_MAKE_TZONE_ALLOCATED(MessagePort); @@ -67,10 +67,17 @@ class MessagePort final : public ContextDestructionObserver, public EventTarget, static Ref create(ScriptExecutionContext&, Ref&&, uint8_t side); virtual ~MessagePort(); + // ActiveDOMObject. + void ref() const final { ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr::ref(); } + void deref() const final { ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr::deref(); } + USING_CAN_MAKE_WEAKPTR(EventTarget); + ExceptionOr postMessage(JSC::JSGlobalObject&, JSC::JSValue message, StructuredSerializeOptions&&); void start(); bool hasMessageEventListener() const { return m_hasMessageEventListener; } + // The worker's entry module finished evaluating: a start() requested before that takes effect now. + void entrySettled(); void close(); // Called on the entangled peer when this side closes: dispatches a // 'close' event and releases the event-loop ref so the loop can idle. @@ -95,20 +102,16 @@ class MessagePort final : public ContextDestructionObserver, public EventTarget, MessagePortPipe* pipe() const { return m_pipe.ptr(); } uint8_t side() const { return m_side; } - void ref() const { ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr::ref(); } - void deref() const { ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr::deref(); } - // EventTarget. EventTargetInterface eventTargetInterface() const final { return MessagePortEventTargetInterfaceType; } - ScriptExecutionContext* scriptExecutionContext() const final { return this->ContextDestructionObserver::scriptExecutionContext(); } + ScriptExecutionContext* scriptExecutionContext() const final { return ActiveDOMObject::scriptExecutionContext(); } void refEventTarget() final { ref(); } void derefEventTarget() final { deref(); } void dispatchEvent(Event&) final; // node:worker_threads receiveMessageOnPort — synchronous single pop. - JSValue tryTakeMessage(JSGlobalObject*); - - bool hasPendingActivity() const; + // The message may legitimately be `undefined`/falsy, so emptiness is reported through hadMessage. + JSValue tryTakeMessage(JSGlobalObject*, bool& hadMessage); void jsRef(JSGlobalObject*); void jsUnref(JSGlobalObject*); @@ -121,7 +124,10 @@ class MessagePort final : public ContextDestructionObserver, public EventTarget, bool addEventListener(const AtomString& eventType, Ref&&, const AddEventListenerOptions&) final; bool removeEventListener(const AtomString& eventType, EventListener&, const EventListenerOptions&) final; + // ActiveDOMObject. void contextDestroyed() final; + void stop() final { close(); } + bool virtualHasPendingActivity() const final; // Deliver messages already queued when close() is called, before teardown. void flushQueuedMessagesBeforeClose(); @@ -148,10 +154,8 @@ class MessagePort final : public ContextDestructionObserver, public EventTarget, // else drops whatever is still queued. bool m_isDispatching { false }; bool m_closeEventDispatched { false }; - // Set while the deferred close task is queued: hasPendingActivity() must keep - // the wrapper alive until it runs, or the task dispatches into a dead listener. - std::atomic m_closeEventPending { false }; bool m_hasMessageEventListener { false }; + bool m_startDeferredUntilEntrySettled { false }; // Read from the GC thread: a port whose only listener is 'close' must survive // until that event is delivered, or the peer's close is lost to a collection. std::atomic m_hasCloseEventListener { false }; diff --git a/src/jsc/bindings/webcore/MessagePortPipe.cpp b/src/jsc/bindings/webcore/MessagePortPipe.cpp index 88f36aef6a18..cc68963b447e 100644 --- a/src/jsc/bindings/webcore/MessagePortPipe.cpp +++ b/src/jsc/bindings/webcore/MessagePortPipe.cpp @@ -88,13 +88,15 @@ void MessagePortPipe::drainAndDispatch(uint8_t side, ScriptExecutionContextIdent // drain task processes the whole inbox in a loop, draining microtasks // between each delivery so queueMicrotask/Promise callbacks observe // messages one at a time, but without a separate posted task per - // message. The per-invocation limit is max(initial queue size, 1000) - // — enough to amortize the uv_async-style reschedule cost, capped so a - // fast sender can't starve the event loop indefinitely. + // message. The per-invocation limit is a fixed count (not "whatever was + // queued when the drain began", which a sender on another thread can make + // arbitrarily large); the rest continues after the loop has polled. // - // Messages are popped one at a time under the lock, so if the handler - // transfers this port (pipe->detach clears `s.port`/`Attached`) the - // remaining inbox stays buffered for the new owner. + // Messages move inbox -> `draining` a small batch per lock acquisition and are + // popped from `draining` one at a time (still under the lock, but without a + // sender contending for it per message). If the handler transfers this port + // (pipe->detach clears `s.port`/`Attached`), detach() splices `draining` back + // in front of the inbox so everything stays buffered, in order, for the new owner. auto& s = m_sides[side]; RefPtr port; @@ -110,11 +112,11 @@ void MessagePortPipe::drainAndDispatch(uint8_t side, ScriptExecutionContextIdent return; port = s.port.get(); uint64_t st = s.state.load(std::memory_order_relaxed); - if (!port || s.inbox.isEmpty()) { + if (!port || (s.draining.isEmpty() && s.inbox.isEmpty())) { s.state.store(st & ~DrainScheduled, std::memory_order_release); return; } - limit = std::max(s.inbox.size(), 1000); + limit = 1024; } // All 'message' listeners removed: the port is paused. Leave the inbox buffered @@ -133,6 +135,7 @@ void MessagePortPipe::drainAndDispatch(uint8_t side, ScriptExecutionContextIdent } auto* globalObject = defaultGlobalObject(context->globalObject()); + static constexpr size_t takeAtOnce = 64; ScriptExecutionContextIdentifier rescheduleCtx = 0; while (true) { std::optional message; @@ -143,22 +146,30 @@ void MessagePortPipe::drainAndDispatch(uint8_t side, ScriptExecutionContextIdent // detach+re-attach restores ctxId but installs a different // MessagePort, so compare port identity too — dispatching to // the stale (now m_isDetached) `port` would silently drop. - // The new owner's attach() scheduled its own drain; leave the - // inbox for that. + // The new owner's attach() scheduled its own drain, and detach() + // already returned anything we had taken to the inbox. if (s.ctxId != expectedCtx || s.port.get() != port) break; uint64_t st = s.state.load(std::memory_order_relaxed); - if (!(st & Attached) || s.inbox.isEmpty()) { + if (!(st & Attached) || (s.draining.isEmpty() && s.inbox.isEmpty())) { s.state.store(st & ~DrainScheduled, std::memory_order_release); break; } - if (limit-- == 0) { - // Yield to the rest of the event loop; DrainScheduled stays - // set so concurrent sends don't double-schedule. - rescheduleCtx = s.ctxId; - break; + if (s.draining.isEmpty()) { + if (!limit) { + // Yield to the rest of the event loop; DrainScheduled stays + // set so concurrent sends don't double-schedule. + rescheduleCtx = s.ctxId; + break; + } + // Refill: this is the only acquisition that contends with senders + // for more than one message's worth of work. + size_t n = std::min({ takeAtOnce, limit, static_cast(s.inbox.size()) }); + for (size_t i = 0; i < n; ++i) + s.draining.append(s.inbox.takeFirst()); + limit -= n; } - message = s.inbox.takeFirst(); + message = s.draining.takeFirst(); s.state.store(st - QueuedOne, std::memory_order_release); } @@ -174,13 +185,20 @@ void MessagePortPipe::drainAndDispatch(uint8_t side, ScriptExecutionContextIdent // pre-loop check instead of dispatching the rest to zero listeners. if (!port->hasMessageEventListener()) { Locker locker { s.lock }; + while (!s.draining.isEmpty()) + s.inbox.prepend(s.draining.takeLast()); s.state.fetch_and(~uint64_t(DrainScheduled), std::memory_order_acq_rel); break; } } - if (rescheduleCtx) - scheduleDrain(side, rescheduleCtx); + // Budget spent with messages left. We are on `context`'s thread: continue on + // its next loop iteration (after I/O and timers), not in this drain. + if (rescheduleCtx) { + context->postTaskAfterYield([pipe = Ref { *this }, side, rescheduleCtx](ScriptExecutionContext&) { + pipe->drainAndDispatch(side, rescheduleCtx); + }); + } } std::optional MessagePortPipe::takeOne(uint8_t side) @@ -188,10 +206,13 @@ std::optional MessagePortPipe::takeOne(uint8_t side) ASSERT(side < 2); auto& s = m_sides[side]; Locker locker { s.lock }; - if (s.inbox.isEmpty()) + // From inside a handler (receiveMessageOnPort), the next message in order may + // already sit in the drain's batch. + auto& queue = s.draining.isEmpty() ? s.inbox : s.draining; + if (queue.isEmpty()) return std::nullopt; s.state.fetch_sub(QueuedOne, std::memory_order_acq_rel); - return s.inbox.takeFirst(); + return queue.takeFirst(); } void MessagePortPipe::attach(uint8_t side, ScriptExecutionContextIdentifier ctxId, ThreadSafeWeakPtr port) @@ -245,6 +266,9 @@ void MessagePortPipe::detach(uint8_t side) ASSERT(side < 2); auto& s = m_sides[side]; Locker locker { s.lock }; + // Taken for dispatch by the owner that is letting go: back in front, in order. + while (!s.draining.isEmpty()) + s.inbox.prepend(s.draining.takeLast()); s.ctxId = 0; s.port = nullptr; // Drop Attached and DrainScheduled. A drain task already in flight on @@ -280,6 +304,8 @@ void MessagePortPipe::close(uint8_t side, CloseKind kind) // Closed is terminal; queued messages are dropped. s.state.store(sdKind == CloseKind::Explicit ? (Closed | ClosedByRequest) : Closed, std::memory_order_release); dropped = std::exchange(s.inbox, {}); + while (!s.draining.isEmpty()) + dropped.prepend(s.draining.takeLast()); } // Harvest transferred pipes before `dropped` destructs so their diff --git a/src/jsc/bindings/webcore/MessagePortPipe.h b/src/jsc/bindings/webcore/MessagePortPipe.h index e58fd648e3db..f4d85717e000 100644 --- a/src/jsc/bindings/webcore/MessagePortPipe.h +++ b/src/jsc/bindings/webcore/MessagePortPipe.h @@ -9,12 +9,13 @@ // deciding whether to schedule a wakeup) can observe a consistent snapshot. // // Wakeups are coalesced: a burst of N sends schedules one cross-thread drain -// task on the receiving context. The drain task loops, popping one message at -// a time under the lock and dispatching it, draining microtasks between each -// (matching Node's MakeCallback / InternalCallbackScope behavior), up to -// max(initial-queue-size, 1000) iterations before yielding back to the event -// loop. Messages stay in the inbox until the instant they are dispatched, so -// a port transferred mid-loop carries the remaining queue to the new owner. +// task on the receiving context. The drain task moves messages inbox -> +// `Side::draining` a small batch per lock acquisition and dispatches them one +// at a time, draining microtasks between each (matching Node's MakeCallback / +// InternalCallbackScope behavior), up to a fixed 1024 per task before +// continuing on the loop's next iteration. A port transferred mid-loop carries +// the whole remaining queue to the new owner: detach() puts `draining` back in +// front of the inbox, in order. // // The Web API semantics (start(), close(), transfer, event dispatch) live in // MessagePort; this class knows nothing about EventTarget or JS. @@ -95,6 +96,11 @@ class MessagePortPipe final : public ThreadSafeRefCounted { struct Side { WTF::Lock lock; WTF::Deque inbox WTF_GUARDED_BY_LOCK(lock); + // Messages the owner's drain has taken out of `inbox` (a small batch per lock + // acquisition) but not dispatched yet. Still counted as queued in `state`. If the + // handler transfers the port mid-batch, detach() puts them back in front of `inbox` + // so the next owner sees everything, in order; close() drops them with the rest. + WTF::Deque draining WTF_GUARDED_BY_LOCK(lock); ScriptExecutionContextIdentifier ctxId WTF_GUARDED_BY_LOCK(lock) { 0 }; ThreadSafeWeakPtr port WTF_GUARDED_BY_LOCK(lock); // Packed flags + count. Written only while holding `lock`; read locklessly. diff --git a/src/jsc/bindings/webcore/Performance.h b/src/jsc/bindings/webcore/Performance.h index da40f2737ff9..d6149dc59496 100644 --- a/src/jsc/bindings/webcore/Performance.h +++ b/src/jsc/bindings/webcore/Performance.h @@ -113,8 +113,10 @@ class Performance final : public RefCounted, public ContextDestruct ScriptExecutionContext* scriptExecutionContext() const final { return ContextDestructionObserver::scriptExecutionContext(); } - using RefCounted::deref; - using RefCounted::ref; + // ContextDestructionObserver. + void ref() const final { RefCounted::ref(); } + void deref() const final { RefCounted::deref(); } + USING_CAN_MAKE_WEAKPTR(EventTarget); // PerformanceNavigationTiming* navigationTiming() { return m_navigationTiming.get(); } diff --git a/src/jsc/bindings/webcore/PerformanceObserverCallback.h b/src/jsc/bindings/webcore/PerformanceObserverCallback.h index 662492215d53..a3608abb1b77 100644 --- a/src/jsc/bindings/webcore/PerformanceObserverCallback.h +++ b/src/jsc/bindings/webcore/PerformanceObserverCallback.h @@ -38,6 +38,10 @@ class PerformanceObserverCallback : public RefCounted handleEvent(PerformanceObserver&, PerformanceObserverEntryList&, PerformanceObserver&) = 0; diff --git a/src/jsc/bindings/webcore/StructuredClone.cpp b/src/jsc/bindings/webcore/StructuredClone.cpp index 40c7e7af9de3..197abd58c5dc 100644 --- a/src/jsc/bindings/webcore/StructuredClone.cpp +++ b/src/jsc/bindings/webcore/StructuredClone.cpp @@ -66,7 +66,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionStructuredClone, (JSC::JSGlobalObject * globa WebCore::propagateException(*globalObject, throwScope, serialized.releaseException()); RELEASE_AND_RETURN(throwScope, {}); } - throwScope.assertNoException(); + RETURN_IF_EXCEPTION(throwScope, {}); JSValue deserialized = serialized.releaseReturnValue()->deserialize(*globalObject, globalObject, ports); RETURN_IF_EXCEPTION(throwScope, {}); @@ -129,7 +129,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionStructuredCloneAdvanced, (JSC::JSGlobalObject WebCore::propagateException(*globalObject, throwScope, serialized.releaseException()); RELEASE_AND_RETURN(throwScope, {}); } - throwScope.assertNoException(); + RETURN_IF_EXCEPTION(throwScope, {}); JSValue deserialized = serialized.releaseReturnValue()->deserialize(*globalObject, globalObject, ports); RETURN_IF_EXCEPTION(throwScope, {}); diff --git a/src/jsc/bindings/webcore/TaskSource.h b/src/jsc/bindings/webcore/TaskSource.h new file mode 100644 index 000000000000..27f03d080c7c --- /dev/null +++ b/src/jsc/bindings/webcore/TaskSource.h @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2019 Apple Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS + * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +namespace WebCore { + +enum class TaskSource : uint8_t { + DOMManipulation, + FileReading, + Networking, + PerformanceTimeline, + PostedMessageQueue, + Timer, + WebSocket, + + // Internal to WebCore + InternalAsyncTask, // Safe to re-order or delay. +}; + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/WebSocket.cpp b/src/jsc/bindings/webcore/WebSocket.cpp index 4889c8483914..0105d7fce609 100644 --- a/src/jsc/bindings/webcore/WebSocket.cpp +++ b/src/jsc/bindings/webcore/WebSocket.cpp @@ -174,27 +174,16 @@ ASCIILiteral WebSocket::subprotocolSeparator() } WebSocket::WebSocket(ScriptExecutionContext& context) - : ContextDestructionObserver(&context) + : ActiveDOMObject(&context) , m_subprotocol(emptyString()) , m_extensions(emptyString()) { - m_state = CONNECTING; - m_hasPendingActivity.store(true); m_rejectUnauthorized = Bun__getTLSRejectUnauthorizedValue() != 0; } WebSocket::~WebSocket() { - if (m_upgradeClient != nullptr) { - void* upgradeClient = m_upgradeClient; - // Use TLS cancel if connection type is TLS or ProxyTLS (either is a TLS socket to the remote) - bool useTLSClient = (m_connectionType == ConnectionType::TLS || m_connectionType == ConnectionType::ProxyTLS); - if (useTLSClient) { - Bun__WebSocketHTTPSClient__cancel(reinterpret_cast(upgradeClient)); - } else { - Bun__WebSocketHTTPClient__cancel(reinterpret_cast(upgradeClient)); - } - } + cancelUpgradeClient(); switch (m_connectedWebSocketKind) { case ConnectedWebSocketKind::Client: { @@ -301,7 +290,10 @@ ExceptionOr> WebSocket::create(ScriptExecutionContext& context, c return Exception { SyntaxError }; auto socket = adoptRef(*new WebSocket(context)); - // socket->suspendIfNeeded(); + socket->suspendIfNeeded(); + // Stopped at birth (its context's active objects were already stopped): stays a CLOSED socket. + if (socket->m_state == CLOSED) + return socket; auto result = socket->connect(url, protocols, WTF::move(headers)); // auto result = socket->connect(url, protocols); @@ -322,6 +314,10 @@ ExceptionOr> WebSocket::create(ScriptExecutionContext& context, c return proxyConfigResult.releaseException(); auto socket = adoptRef(*new WebSocket(context)); + socket->suspendIfNeeded(); + // Stopped at birth (its context's active objects were already stopped): stays a CLOSED socket. + if (socket->m_state == CLOSED) + return socket; socket->m_sslConfig = WTF::move(sslConfig); // Set BEFORE connect() so it's available during connection socket->setOfferPerMessageDeflate(offerPerMessageDeflate); @@ -342,6 +338,10 @@ ExceptionOr> WebSocket::create(ScriptExecutionContext& context, c return proxyConfigResult.releaseException(); auto socket = adoptRef(*new WebSocket(context)); + socket->suspendIfNeeded(); + // Stopped at birth (its context's active objects were already stopped): stays a CLOSED socket. + if (socket->m_state == CLOSED) + return socket; socket->setRejectUnauthorized(rejectUnauthorized); socket->m_sslConfig = WTF::move(sslConfig); // Set BEFORE connect() so it's available during connection socket->setOfferPerMessageDeflate(offerPerMessageDeflate); @@ -420,7 +420,6 @@ ExceptionOr WebSocket::connect(const String& url, const Vector& pr if (!m_url.isValid()) { // context.addConsoleMessage(MessageSource::JS, MessageLevel::Error, ); m_state = CLOSED; - updateHasPendingActivity(); return Exception { SyntaxError, makeString("Invalid url for WebSocket "_s, m_url.stringCenterEllipsizedToLength()) }; } @@ -430,13 +429,11 @@ ExceptionOr WebSocket::connect(const String& url, const Vector& pr if (!m_url.protocolIs("http"_s) && !m_url.protocolIs("ws"_s) && !is_secure && !is_unix) { // context.addConsoleMessage(MessageSource::JS, MessageLevel::Error, ); m_state = CLOSED; - updateHasPendingActivity(); return Exception { SyntaxError, makeString("Wrong url scheme for WebSocket "_s, m_url.stringCenterEllipsizedToLength()) }; } if (m_url.hasFragmentIdentifier()) { // context.addConsoleMessage(MessageSource::JS, MessageLevel::Error, ); m_state = CLOSED; - updateHasPendingActivity(); return Exception { SyntaxError, makeString("URL has fragment component "_s, m_url.stringCenterEllipsizedToLength()) }; } @@ -451,7 +448,6 @@ ExceptionOr WebSocket::connect(const String& url, const Vector& pr if (!isValidProtocolString(protocol)) { // context.addConsoleMessage(MessageSource::JS, MessageLevel::Error, ); m_state = CLOSED; - updateHasPendingActivity(); return Exception { SyntaxError, makeString("Wrong protocol for WebSocket '"_s, encodeProtocolString(protocol), "'"_s) }; } } @@ -460,7 +456,6 @@ ExceptionOr WebSocket::connect(const String& url, const Vector& pr if (!visited.add(protocol).isNewEntry) { // context.addConsoleMessage(MessageSource::JS, MessageLevel::Error, ); m_state = CLOSED; - updateHasPendingActivity(); return Exception { SyntaxError, makeString("WebSocket protocols contain duplicates:"_s, encodeProtocolString(protocol), "'"_s) }; } } @@ -486,7 +481,6 @@ ExceptionOr WebSocket::connect(const String& url, const Vector& pr auto pathname = m_url.path(); if (pathname.isEmpty()) { m_state = CLOSED; - updateHasPendingActivity(); return Exception { SyntaxError, makeString("Invalid url for WebSocket "_s, m_url.stringCenterEllipsizedToLength(), " (missing unix socket path)"_s) }; } size_t colon = pathname.find(':'); @@ -507,7 +501,6 @@ ExceptionOr WebSocket::connect(const String& url, const Vector& pr } if (unixSocketPathString.isEmpty()) { m_state = CLOSED; - updateHasPendingActivity(); return Exception { SyntaxError, makeString("Invalid url for WebSocket "_s, m_url.stringCenterEllipsizedToLength(), " (missing unix socket path)"_s) }; } // Host header defaults to "localhost" over a unix socket, matching @@ -534,7 +527,6 @@ ExceptionOr WebSocket::connect(const String& url, const Vector& pr auto headersOrException = FetchHeaders::create(WTF::move(headersInit)); if (headersOrException.hasException()) [[unlikely]] { m_state = CLOSED; - updateHasPendingActivity(); return headersOrException.releaseException(); } @@ -586,7 +578,7 @@ ExceptionOr WebSocket::connect(const String& url, const Vector& pr m_connectionType = is_secure ? ConnectionType::TLS : ConnectionType::Plain; } - this->incPendingActivityCount(); + m_pendingActivity = makePendingActivity(*this); // Prepare proxy parameters (use local variables, not member fields). // The BunString wrappers reference the underlying WTF::Strings in proxyConfig @@ -658,24 +650,20 @@ ExceptionOr WebSocket::connect(const String& url, const Vector& pr if (this->m_upgradeClient == nullptr) { m_state = CLOSED; - if (auto* context = scriptExecutionContext()) { - context->postTask([this, protectedThis = Ref { *this }](ScriptExecutionContext& context) { - ASSERT(scriptExecutionContext()); - auto* globalObject = context.jsGlobalObject(); - - auto eventInit = createErrorEventInit(protectedThis, "Failed to connect"_s, globalObject); + if (scriptExecutionContext()) { + queueTaskKeepingObjectAlive(*this, TaskSource::WebSocket, [](WebSocket& ws) { + auto eventInit = createErrorEventInit(ws, "Failed to connect"_s, ws.scriptExecutionContext()->jsGlobalObject()); auto message = eventInit.message; - protectedThis->dispatchEvent(ErrorEvent::create(eventNames().errorEvent, WTF::move(eventInit), EventIsTrusted::Yes)); - protectedThis->dispatchEvent(CloseEvent::create(false, 1006, WTF::move(message))); - - protectedThis->decPendingActivityCount(); + ws.dispatchEvent(ErrorEvent::create(eventNames().errorEvent, WTF::move(eventInit), EventIsTrusted::Yes)); + ws.dispatchEvent(CloseEvent::create(false, 1006, WTF::move(message))); }); } + // create() still holds a Ref, so releasing connect()'s claim here cannot destroy `this`. + m_pendingActivity = nullptr; return {}; } m_state = CONNECTING; - updateHasPendingActivity(); return {}; } @@ -799,7 +787,6 @@ void WebSocket::sendWebSocketString(const String& message, const Opcode op) RELEASE_ASSERT_NOT_REACHED(); } } - updateHasPendingActivity(); } // Called from close()/terminate() while m_state == CONNECTING. @@ -810,48 +797,36 @@ void WebSocket::sendWebSocketString(const String& message, const Opcode op) // must therefore finish the close ourselves: cancel the upgrade, queue // a task that moves to CLOSED, fires error + close events (spec "fail // the WebSocket connection" path — code 1006, wasClean false), and -// releases the pending-activity ref taken in connect(). Without this -// the WebSocket stays in CLOSING with m_pendingActivityCount > 0 -// forever and is never garbage-collected. +// releases the pending activity connect() took. Without this the WebSocket +// stays in CLOSING, pinned by that pending activity, and is never collected. void WebSocket::failConnectingWebSocket() { ASSERT(m_state == CONNECTING); m_state = CLOSING; + cancelUpgradeClient(); - if (m_upgradeClient != nullptr) { - void* upgradeClient = m_upgradeClient; - m_upgradeClient = nullptr; - bool useTLSClient = (m_connectionType == ConnectionType::TLS || m_connectionType == ConnectionType::ProxyTLS); - if (useTLSClient) { - Bun__WebSocketHTTPSClient__cancel(upgradeClient); - } else { - Bun__WebSocketHTTPClient__cancel(upgradeClient); - } - } - - if (auto* context = scriptExecutionContext()) { - context->postTask([protectedThis = Ref { *this }](ScriptExecutionContext& context) { - if (protectedThis->m_state == CLOSED) - return; - protectedThis->m_state = CLOSED; - if (protectedThis->m_native.onClose) { - protectedThis->m_native.onClose(protectedThis->m_native.ctx, 1006); - } else { - // Spec: close() while CONNECTING runs "fail the WebSocket - // connection", which requires an error event before the - // close event. Matches Chrome/Firefox and npm ws. - auto reason = "WebSocket is closed before the connection is established"_s; - auto eventInit = createErrorEventInit(protectedThis, reason, context.jsGlobalObject()); - protectedThis->dispatchEvent(ErrorEvent::create(eventNames().errorEvent, WTF::move(eventInit), EventIsTrusted::Yes)); - protectedThis->dispatchEvent(CloseEvent::create(false, 1006, reason)); - } - protectedThis->disablePendingActivity(); - }); - } else { + if (!scriptExecutionContext()) { m_state = CLOSED; - disablePendingActivity(); + m_pendingActivity = nullptr; // may destroy `this` + return; } - updateHasPendingActivity(); + queueTaskKeepingObjectAlive(*this, TaskSource::WebSocket, [](WebSocket& ws) { + if (ws.m_state == CLOSED) + return; + ws.m_state = CLOSED; + if (ws.m_native.onClose) { + ws.m_native.onClose(ws.m_native.ctx, 1006); + return; + } + // Spec: close() while CONNECTING runs "fail the WebSocket connection", which requires + // an error event before the close event. Matches Chrome/Firefox and npm ws. + auto reason = "WebSocket is closed before the connection is established"_s; + auto eventInit = createErrorEventInit(ws, reason, ws.scriptExecutionContext()->jsGlobalObject()); + ws.dispatchEvent(ErrorEvent::create(eventNames().errorEvent, WTF::move(eventInit), EventIsTrusted::Yes)); + ws.dispatchEvent(CloseEvent::create(false, 1006, reason)); + }); + // The queued task now holds its own claim; connect()'s is over. + m_pendingActivity = nullptr; } ExceptionOr WebSocket::close(std::optional optionalCode, const String& reason) @@ -880,14 +855,12 @@ ExceptionOr WebSocket::close(std::optional optionalCode, c case ConnectedWebSocketKind::Client: { ZigString reasonZigStr = Zig::toZigString(reason); Bun__WebSocketClient__close(this->m_connectedWebSocket.client, code, &reasonZigStr); - updateHasPendingActivity(); // this->m_bufferedAmount = this->m_connectedWebSocket.client->getBufferedAmount(); break; } case ConnectedWebSocketKind::ClientSSL: { ZigString reasonZigStr = Zig::toZigString(reason); Bun__WebSocketClientTLS__close(this->m_connectedWebSocket.clientSSL, code, &reasonZigStr); - updateHasPendingActivity(); // this->m_bufferedAmount = this->m_connectedWebSocket.clientSSL->getBufferedAmount(); break; } @@ -899,7 +872,6 @@ ExceptionOr WebSocket::close(std::optional optionalCode, c // be written in one send(), the native client defers didClose() until the // frame has drained, and didClose() early-returns if the kind is already // None. didClose() itself clears it once the close completes. - updateHasPendingActivity(); return {}; } @@ -914,24 +886,54 @@ ExceptionOr WebSocket::terminate() return {}; } m_state = CLOSING; - switch (m_connectedWebSocketKind) { - case ConnectedWebSocketKind::Client: { - Bun__WebSocketClient__cancel(this->m_connectedWebSocket.client); - updateHasPendingActivity(); + cancelConnectedClient(); + return {}; +} + +void WebSocket::cancelUpgradeClient() +{ + auto* upgradeClient = std::exchange(m_upgradeClient, nullptr); + if (!upgradeClient) + return; + // TLS and ProxyTLS both hold a TLS socket to the next hop. + if (m_connectionType == ConnectionType::TLS || m_connectionType == ConnectionType::ProxyTLS) + Bun__WebSocketHTTPSClient__cancel(upgradeClient); + else + Bun__WebSocketHTTPClient__cancel(upgradeClient); +} + +void WebSocket::cancelConnectedClient() +{ + switch (std::exchange(m_connectedWebSocketKind, ConnectedWebSocketKind::None)) { + case ConnectedWebSocketKind::Client: + Bun__WebSocketClient__cancel(std::exchange(m_connectedWebSocket.client, nullptr)); break; - } - case ConnectedWebSocketKind::ClientSSL: { - Bun__WebSocketClientTLS__cancel(this->m_connectedWebSocket.clientSSL); - updateHasPendingActivity(); + case ConnectedWebSocketKind::ClientSSL: + Bun__WebSocketClientTLS__cancel(std::exchange(m_connectedWebSocket.clientSSL, nullptr)); break; - } - default: { + case ConnectedWebSocketKind::None: break; } +} + +// The context is being torn down: drop the connection now, without a closing handshake, without +// dispatching anything and without running script (this runs inside stopActiveDOMObjects()). The +// connected client is told to forget this object before it closes, so nothing calls back here. +void WebSocket::stop() +{ + m_state = CLOSED; + cancelUpgradeClient(); + switch (std::exchange(m_connectedWebSocketKind, ConnectedWebSocketKind::None)) { + case ConnectedWebSocketKind::Client: + Bun__WebSocketClient__dropConnectionWithoutCallback(std::exchange(m_connectedWebSocket.client, nullptr)); + break; + case ConnectedWebSocketKind::ClientSSL: + Bun__WebSocketClientTLS__dropConnectionWithoutCallback(std::exchange(m_connectedWebSocket.clientSSL, nullptr)); + break; + case ConnectedWebSocketKind::None: + break; } - this->m_connectedWebSocketKind = ConnectedWebSocketKind::None; - updateHasPendingActivity(); - return {}; + m_pendingActivity = nullptr; } ExceptionOr WebSocket::ping() @@ -1170,7 +1172,7 @@ EventTargetInterface WebSocket::eventTargetInterface() const ScriptExecutionContext* WebSocket::scriptExecutionContext() const { - return ContextDestructionObserver::scriptExecutionContext(); + return ActiveDOMObject::scriptExecutionContext(); } void WebSocket::didConnect() @@ -1190,26 +1192,21 @@ void WebSocket::didConnect() // Native callback: fire synchronously, skip Event construction + // dispatchEvent + the postTask deferral. The m_state = OPEN above is // the only bookkeeping the native path needs — sendTextNative checks - // it. incPendingActivityCount is a JS-GC-root concern; the native + // it. Keeping the wrapper alive is a script concern; the native // consumer holds a RefPtr so it doesn't need it. if (m_native.onOpen) { m_native.onOpen(m_native.ctx); return; } - if (auto* context = scriptExecutionContext()) { + if (scriptExecutionContext()) { if (this->hasEventListeners("open"_s)) { - this->incPendingActivityCount(); // the main reason for dispatching on a separate tick is to handle when you haven't yet attached an event listener dispatchEvent(Event::create(eventNames().openEvent, Event::CanBubble::No, Event::IsCancelable::No)); - this->decPendingActivityCount(); } else { - this->incPendingActivityCount(); - context->postTask([this, protectedThis = Ref { *this }](ScriptExecutionContext& context) { - ASSERT(scriptExecutionContext()); - protectedThis->dispatchEvent(Event::create(eventNames().openEvent, Event::CanBubble::No, Event::IsCancelable::No)); - protectedThis->decPendingActivityCount(); + queueTaskKeepingObjectAlive(*this, TaskSource::WebSocket, [](WebSocket& ws) mutable { + ws.dispatchEvent(Event::create(eventNames().openEvent, Event::CanBubble::No, Event::IsCancelable::No)); }); } } @@ -1236,18 +1233,13 @@ void WebSocket::didReceiveMessage(String&& message) if (this->hasEventListeners("message"_s)) { // the main reason for dispatching on a separate tick is to handle when you haven't yet attached an event listener - this->incPendingActivityCount(); dispatchEvent(MessageEvent::create(WTF::move(message), m_url.string())); - this->decPendingActivityCount(); return; } - if (auto* context = scriptExecutionContext()) { - this->incPendingActivityCount(); - context->postTask([this, message_ = WTF::move(message), protectedThis = Ref { *this }](ScriptExecutionContext& context) { - ASSERT(scriptExecutionContext()); - protectedThis->dispatchEvent(MessageEvent::create(message_, protectedThis->m_url.string())); - protectedThis->decPendingActivityCount(); + if (scriptExecutionContext()) { + queueTaskKeepingObjectAlive(*this, TaskSource::WebSocket, [message_ = WTF::move(message)](WebSocket& ws) mutable { + ws.dispatchEvent(MessageEvent::create(message_, ws.m_url.string())); }); } @@ -1269,20 +1261,15 @@ void WebSocket::didReceiveBinaryData(const AtomString& eventName, const std::spa case BinaryType::Blob: if (this->hasEventListeners(eventName)) { // the main reason for dispatching on a separate tick is to handle when you haven't yet attached an event listener - this->incPendingActivityCount(); RefPtr blob = Blob::create(binaryData, scriptExecutionContext()->jsGlobalObject()); dispatchEvent(MessageEvent::create(eventName, blob.releaseNonNull(), m_url.string())); - this->decPendingActivityCount(); return; } if (auto* context = scriptExecutionContext()) { RefPtr blob = Blob::create(binaryData, context->jsGlobalObject()); - this->incPendingActivityCount(); - context->postTask([this, name = eventName, blob = blob.releaseNonNull(), protectedThis = Ref { *this }](ScriptExecutionContext& context) { - ASSERT(scriptExecutionContext()); - protectedThis->dispatchEvent(MessageEvent::create(name, blob, protectedThis->m_url.string())); - protectedThis->decPendingActivityCount(); + queueTaskKeepingObjectAlive(*this, TaskSource::WebSocket, [name = eventName, blob = blob.releaseNonNull()](WebSocket& ws) mutable { + ws.dispatchEvent(MessageEvent::create(name, blob, ws.m_url.string())); }); } @@ -1290,19 +1277,14 @@ void WebSocket::didReceiveBinaryData(const AtomString& eventName, const std::spa case BinaryType::ArrayBuffer: { if (this->hasEventListeners(eventName)) { // the main reason for dispatching on a separate tick is to handle when you haven't yet attached an event listener - this->incPendingActivityCount(); dispatchEvent(MessageEvent::create(eventName, ArrayBuffer::create(binaryData), m_url.string())); - this->decPendingActivityCount(); return; } - if (auto* context = scriptExecutionContext()) { + if (scriptExecutionContext()) { auto arrayBuffer = JSC::ArrayBuffer::create(binaryData); - this->incPendingActivityCount(); - context->postTask([this, name = eventName, buffer = WTF::move(arrayBuffer), protectedThis = Ref { *this }](ScriptExecutionContext& context) { - ASSERT(scriptExecutionContext()); - protectedThis->dispatchEvent(MessageEvent::create(name, buffer, m_url.string())); - protectedThis->decPendingActivityCount(); + queueTaskKeepingObjectAlive(*this, TaskSource::WebSocket, [name = eventName, buffer = WTF::move(arrayBuffer)](WebSocket& ws) mutable { + ws.dispatchEvent(MessageEvent::create(name, buffer, ws.m_url.string())); }); } @@ -1311,8 +1293,6 @@ void WebSocket::didReceiveBinaryData(const AtomString& eventName, const std::spa case BinaryType::NodeBuffer: { if (this->hasEventListeners(eventName)) { - // the main reason for dispatching on a separate tick is to handle when you haven't yet attached an event listener - this->incPendingActivityCount(); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(scriptExecutionContext()->vm()); JSUint8Array* buffer = createBuffer(scriptExecutionContext()->jsGlobalObject(), binaryData); @@ -1322,7 +1302,6 @@ void WebSocket::didReceiveBinaryData(const AtomString& eventName, const std::spa ErrorEvent::Init errorInit; errorInit.message = "Failed to allocate memory for binary data"_s; dispatchEvent(ErrorEvent::create(eventNames().errorEvent, errorInit)); - this->decPendingActivityCount(); return; } @@ -1332,26 +1311,21 @@ void WebSocket::didReceiveBinaryData(const AtomString& eventName, const std::spa init.origin = this->m_url.string(); dispatchEvent(MessageEvent::create(eventName, WTF::move(init), EventIsTrusted::Yes)); - this->decPendingActivityCount(); return; } - if (auto* context = scriptExecutionContext()) { - auto arrayBuffer = JSC::ArrayBuffer::tryCreate(binaryData); - - this->incPendingActivityCount(); - - context->postTask([name = eventName, buffer = WTF::move(arrayBuffer), protectedThis = Ref { *this }](ScriptExecutionContext& context) { + // No listener yet: dispatch on a later tick so a listener attached right after still sees it. + if (scriptExecutionContext()) { + queueTaskKeepingObjectAlive(*this, TaskSource::WebSocket, [name = eventName, buffer = JSC::ArrayBuffer::tryCreate(binaryData)](WebSocket& ws) mutable { size_t length = buffer->byteLength(); - auto* globalObject = context.jsGlobalObject(); + auto* globalObject = ws.scriptExecutionContext()->jsGlobalObject(); auto* subclassStructure = static_cast(globalObject)->JSBufferSubclassStructure(); JSUint8Array* uint8array = JSUint8Array::create(globalObject, subclassStructure, buffer.copyRef(), 0, length); JSC::EnsureStillAliveScope ensureStillAlive(uint8array); MessageEvent::Init init; init.data = uint8array; - init.origin = protectedThis->m_url.string(); - protectedThis->dispatchEvent(MessageEvent::create(name, WTF::move(init), EventIsTrusted::Yes)); - protectedThis->decPendingActivityCount(); + init.origin = ws.m_url.string(); + ws.dispatchEvent(MessageEvent::create(name, WTF::move(init), EventIsTrusted::Yes)); }); } @@ -1406,9 +1380,7 @@ void WebSocket::didReceiveHandshakeResponse(uint16_t statusCode, std::spanincPendingActivityCount(); dispatchEvent(MessageEvent::create(eventNames().handshakeEvent, WTF::move(init), EventIsTrusted::Yes)); - this->decPendingActivityCount(); } void WebSocket::didReceiveClose(CleanStatus wasClean, unsigned short code, WTF::String reason, bool isConnectionError) @@ -1422,9 +1394,8 @@ void WebSocket::didReceiveClose(CleanStatus wasClean, unsigned short code, WTF:: // Native callback: state transitioned, hand off the close code. // Covers both connect-failure (didFailWithErrorCode → // didReceiveClose) and server-initiated close. The consumer's - // onClose distinguishes by whether onOpen ever fired. Cleanup - // (disablePendingActivity) is the caller's job — didFailWithErrorCode - // posts it after the switch. + // onClose distinguishes by whether onOpen ever fired. Releasing connect()'s + // pending activity is the caller's job — didFailWithErrorCode posts it after the switch. if (m_native.onClose) { m_state = CLOSED; m_native.onClose(m_native.ctx, code); @@ -1435,21 +1406,17 @@ void WebSocket::didReceiveClose(CleanStatus wasClean, unsigned short code, WTF:: // has already cleared m_connectedWebSocketKind, so move to CLOSING now // to keep send()/ping()/pong() out of sendWebSocketData() with no kind. m_state = CLOSING; - if (auto* context = scriptExecutionContext()) { + if (scriptExecutionContext()) { const bool dispatchError = wasConnecting && isConnectionError; - this->incPendingActivityCount(); - context->postTask([code, dispatchError, reason = WTF::move(reason), clean = wasClean == CleanStatus::Clean, protectedThis = Ref { *this }](ScriptExecutionContext& context) { - if (protectedThis->m_state == CLOSED) { - protectedThis->decPendingActivityCount(); + queueTaskKeepingObjectAlive(*this, TaskSource::WebSocket, [code, dispatchError, reason = WTF::move(reason), clean = wasClean == CleanStatus::Clean](WebSocket& ws) mutable { + if (ws.m_state == CLOSED) return; - } - protectedThis->m_state = CLOSED; + ws.m_state = CLOSED; if (dispatchError) { - auto eventInit = createErrorEventInit(protectedThis, reason, context.jsGlobalObject()); - protectedThis->dispatchEvent(ErrorEvent::create(eventNames().errorEvent, WTF::move(eventInit), EventIsTrusted::Yes)); + auto eventInit = createErrorEventInit(ws, reason, ws.scriptExecutionContext()->jsGlobalObject()); + ws.dispatchEvent(ErrorEvent::create(eventNames().errorEvent, WTF::move(eventInit), EventIsTrusted::Yes)); } - protectedThis->dispatchEvent(CloseEvent::create(clean, code, reason)); - protectedThis->decPendingActivityCount(); + ws.dispatchEvent(CloseEvent::create(clean, code, reason)); }); } else { m_state = CLOSED; @@ -1463,7 +1430,6 @@ void WebSocket::didStartClosingHandshake() if (m_state == CLOSED) return; m_state = CLOSING; - updateHasPendingActivity(); // }); } @@ -1481,20 +1447,14 @@ void WebSocket::didClose(unsigned unhandledBufferedAmount, unsigned short code, this->m_upgradeClient = nullptr; // Native callback: state transition above is done, hand off the code. - // disablePendingActivity releases the GC-root ref connect() took — - // the native consumer holds a RefPtr so GC-rooting doesn't matter, - // but the count should balance for the ASSERT below and any future - // consumer of hasPendingActivity(). if (m_native.onClose) { m_state = CLOSED; m_native.onClose(m_native.ctx, code); - disablePendingActivity(); + m_pendingActivity = nullptr; return; } - // since we are open and closing now we know that we have at least one pending activity - // so we just call decPendingActivityCount() after dispatching the event - ASSERT(m_pendingActivityCount > 0); + ASSERT(m_pendingActivity); // Spec: queue a task to set CLOSED and fire the close event (#15665: this // is reached synchronously from ws.close()). Kind is already None above, @@ -1505,18 +1465,18 @@ void WebSocket::didClose(unsigned unhandledBufferedAmount, unsigned short code, context->postTask([code, wasClean, reason, protectedThis = Ref { *this }](ScriptExecutionContext& context) { ASSERT(protectedThis->scriptExecutionContext()); if (protectedThis->m_state == CLOSED) { - protectedThis->disablePendingActivity(); + protectedThis->m_pendingActivity = nullptr; return; } protectedThis->m_state = CLOSED; protectedThis->dispatchEvent(CloseEvent::create(wasClean, code, reason)); - protectedThis->disablePendingActivity(); + protectedThis->m_pendingActivity = nullptr; }); return; } m_state = CLOSED; - this->disablePendingActivity(); + m_pendingActivity = nullptr; } void WebSocket::didConnect(us_socket_t* socket, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params, void* customSSLCtx) @@ -1713,34 +1673,19 @@ void WebSocket::didFailWithErrorCode(Bun::WebSocketErrorCode code) } } - // didReceiveClose has queued the CLOSED transition. The connect() ref - // kept us alive across the switch (including across the native - // onClose callback dropping its RefPtr); release it from a task so - // the caller's stack frame unwinds first. ContextDestructionObserver - // holds a WeakPtr — a Worker terminated mid-connect returns null - // here; deref directly since there's no loop to post to. + // didReceiveClose has queued the CLOSED transition. connect()'s pending activity kept us alive + // across the switch (including across the native onClose callback dropping its RefPtr); release + // it from a task so the caller's stack frame unwinds first, or right here if the context is + // already gone (a Worker terminated mid-connect). if (auto* context = scriptExecutionContext()) { context->postTask([protectedThis = Ref { *this }](ScriptExecutionContext&) { - protectedThis->disablePendingActivity(); + protectedThis->m_pendingActivity = nullptr; }); } else { - this->deref(); + m_pendingActivity = nullptr; } } -void WebSocket::disablePendingActivity() -{ - this->m_pendingActivityCount = 1; - this->decPendingActivityCount(); -} - -void WebSocket::updateHasPendingActivity() -{ - std::atomic_thread_fence(std::memory_order_acquire); - m_hasPendingActivity.store( - !(m_state == CLOSED && m_pendingActivityCount == 0)); -} - // Forward declarations for tunnel mode (defined outside namespace) extern "C" void* Bun__WebSocketClient__initWithTunnel(CppWebSocket* ws, void* tunnel, JSC::JSGlobalObject* globalObject, unsigned char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params); extern "C" void WebSocketProxyTunnel__setConnectedWebSocket(void* tunnel, void* websocket); @@ -1831,13 +1776,15 @@ extern "C" bool WebSocket__rejectUnauthorized(WebCore::WebSocket* webSocket) return webSocket->rejectUnauthorized(); } -extern "C" void WebSocket__incrementPendingActivity(WebCore::WebSocket* webSocket) +// The native client keeps this object (and its wrapper) alive across work it has queued that will +// call back into it; one such claim at a time. +extern "C" void WebSocket__holdPendingActivityForClient(WebCore::WebSocket* webSocket) { - webSocket->incPendingActivityCount(); + webSocket->holdPendingActivityForClient(); } -extern "C" void WebSocket__decrementPendingActivity(WebCore::WebSocket* webSocket) +extern "C" void WebSocket__releasePendingActivityForClient(WebCore::WebSocket* webSocket) { - webSocket->decPendingActivityCount(); + webSocket->releasePendingActivityForClient(); } WebCore::ExceptionOr WebCore::WebSocket::ping(WebCore::JSBlob* blob) diff --git a/src/jsc/bindings/webcore/WebSocket.h b/src/jsc/bindings/webcore/WebSocket.h index 41bebbec9929..ee1f272209c1 100644 --- a/src/jsc/bindings/webcore/WebSocket.h +++ b/src/jsc/bindings/webcore/WebSocket.h @@ -31,7 +31,7 @@ #pragma once #include "WebSocketDeflate.h" -#include "ContextDestructionObserver.h" +#include "ActiveDOMObject.h" #include "EventTarget.h" #include "ExceptionOr.h" #include @@ -107,10 +107,15 @@ class WebSocketSSLConfigPtr { void* m_ptr { nullptr }; }; -class WebSocket final : public RefCounted, public EventTargetWithInlineData, public ContextDestructionObserver { +class WebSocket final : public RefCounted, public EventTargetWithInlineData, public ActiveDOMObject { WTF_MAKE_TZONE_ALLOCATED(WebSocket); public: + // ActiveDOMObject. + void ref() const final { RefCounted::ref(); } + void deref() const final { RefCounted::deref(); } + USING_CAN_MAKE_WEAKPTR(EventTargetWithInlineData); + static ASCIILiteral subprotocolSeparator(); static ExceptionOr> create(ScriptExecutionContext&, const String& url); @@ -190,10 +195,7 @@ class WebSocket final : public RefCounted, public EventTargetWithInli ScriptExecutionContext* scriptExecutionContext() const final; - using RefCounted::deref; - using RefCounted::ref; void didConnect(); - void disablePendingActivity(); void didStartClosingHandshake(); void didClose(unsigned unhandledBufferedAmount, unsigned short code, const String& reason); void didConnect(us_socket_t* socket, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params, void* customSSLCtx); @@ -210,11 +212,13 @@ class WebSocket final : public RefCounted, public EventTargetWithInli }; void didReceiveHandshakeResponse(uint16_t statusCode, std::span statusMessage, std::span headers, std::span body); - void updateHasPendingActivity(); - bool hasPendingActivity() const + // A single claim the native client holds while it has queued work that will call back in. + void holdPendingActivityForClient() { - return m_hasPendingActivity.load(); + ASSERT(!m_pendingActivityForClient); + m_pendingActivityForClient = makePendingActivity(*this); } + void releasePendingActivityForClient() { m_pendingActivityForClient = nullptr; } void setRejectUnauthorized(bool rejectUnauthorized) { @@ -267,22 +271,6 @@ class WebSocket final : public RefCounted, public EventTargetWithInli return m_rejectUnauthorized; } - void incPendingActivityCount() - { - ASSERT(m_pendingActivityCount < std::numeric_limits::max()); - m_pendingActivityCount++; - ref(); - updateHasPendingActivity(); - } - - void decPendingActivityCount() - { - ASSERT(m_pendingActivityCount > 0); - m_pendingActivityCount--; - updateHasPendingActivity(); - deref(); - } - size_t memoryCost() const; private: @@ -296,7 +284,10 @@ class WebSocket final : public RefCounted, public EventTargetWithInli ClientSSL, }; - std::atomic m_hasPendingActivity { true }; + // ActiveDOMObject. Read from the GC thread; a stale answer keeps or drops the wrapper one + // cycle early or late, as upstream tolerates. + void stop() final; + bool virtualHasPendingActivity() const final { return m_state != CLOSED; } explicit WebSocket(ScriptExecutionContext&); @@ -331,13 +322,20 @@ class WebSocket final : public RefCounted, public EventTargetWithInli String m_extensions; void* m_upgradeClient { nullptr }; ConnectionType m_connectionType { ConnectionType::Plain }; + // Drop the in-flight upgrade / the connected client without a closing handshake. Neither + // dispatches anything itself; the native side may call back synchronously. + void cancelUpgradeClient(); + void cancelConnectedClient(); bool m_rejectUnauthorized { false }; // Default matches pre-existing behavior: advertise permessage-deflate in the upgrade // request. Set to false by ws.WebSocket callers passing `perMessageDeflate: false`. bool m_offerPerMessageDeflate { true }; AnyWebSocket m_connectedWebSocket { nullptr }; ConnectedWebSocketKind m_connectedWebSocketKind { ConnectedWebSocketKind::None }; - size_t m_pendingActivityCount { 0 }; + // connect()'s claim on the wrapper: held from connect() until the socket reaches CLOSED (or + // stop()). Posted event tasks keep it alive through queueTaskKeepingObjectAlive(). + RefPtr> m_pendingActivity; + RefPtr> m_pendingActivityForClient; // TLS options (native heap SSLConfig — ownership is released to the // upgrade client in connect(); freed by ~WebSocketSSLConfigPtr otherwise). diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index 1a97f67c2571..a4b39d1b9df8 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -42,7 +42,7 @@ #include #include #include "MessageEvent.h" -#include "BunWorkerGlobalScope.h" +#include "GlobalEventScope.h" #include "CloseEvent.h" #include "JSDOMConvertObject.h" #include "JSDOMConvertSequences.h" @@ -56,654 +56,111 @@ namespace WebCore { WTF_MAKE_TZONE_ALLOCATED_IMPL(Worker); -// ---- Native FFI -------------------------------------------------------------------------------- -// The native WebWorker struct is owned by this Worker (freed in ~Worker) and drives the worker -// thread. See src/jsc/web_worker.rs for the matching side of each entry point. -extern "C" { - -// Allocate the native WebWorker, take a keep-alive on the parent event loop, and spawn the worker -// thread. Returns null (and sets errorMessage) on any failure; nothing needs cleanup in that case. -void* WebWorker__create( - Worker* worker, - void* parent, - BunString name, - BunString url, - BunString* errorMessage, - uint32_t parentContextId, - uint32_t contextId, - bool miniMode, - bool unrefByDefault, - bool evalMode, - StringImpl** argvPtr, - size_t argvLen, - bool defaultExecArgv, - StringImpl** execArgvPtr, - size_t execArgvLen, - BunString* preloadModulesPtr, - size_t preloadModulesLen); - -// worker.terminate() — set requested_terminate, raise TerminationException in the worker VM, -// wake the worker loop. Parent thread only. -void WebWorker__notifyNeedTermination(void* worker); - -// worker.ref()/.unref() — toggle the keep-alive on the parent event loop. Parent thread only. -void WebWorker__setRef(void* worker, bool ref); - -// Release the keep-alive on the parent event loop. Called from the close task on the parent -// thread. -void WebWorker__releaseParentPollRef(void* worker); - -// Free the native WebWorker struct. Called from ~Worker. -void WebWorker__destroy(void* worker); - -} // extern "C" -// ------------------------------------------------------------------------------------------------- - Worker::Worker(ScriptExecutionContext& context, WorkerOptions&& options) - : EventTargetWithInlineData() - , ContextDestructionObserver(&context) - , m_options(WTF::move(options)) - , m_parentContextId(context.identifier()) - , m_clientIdentifier(ScriptExecutionContext::generateIdentifier()) + : ActiveDOMObject(&context) + , m_name(options.name) + , m_contextProxy(WorkerMessagingProxy::create(*this, context, WTF::move(options))) { } ExceptionOr> Worker::create(ScriptExecutionContext& context, const String& urlInit, WorkerOptions&& options) { - auto worker = adoptRef(*new Worker(context, WTF::move(options))); + ASSERT(context.isContextThread()); - WTF::String url = urlInit; + String url = urlInit; if (url.startsWith("file://"_s)) { - WTF::URL urlObject = WTF::URL(url); - if (urlObject.isValid()) { - url = urlObject.fileSystemPath(); - } else { + WTF::URL urlObject { url }; + if (!urlObject.isValid()) return Exception { TypeError, makeString("Invalid file URL: \""_s, urlInit, '"') }; - } - } - BunString urlStr = Bun::toString(url); - BunString errorMessage = BunStringEmpty; - BunString nameStr = Bun::toString(worker->m_options.name); - - auto& preloadModuleStrings = worker->m_options.preloadModules; - Vector preloadModules; - preloadModules.reserveInitialCapacity(preloadModuleStrings.size()); - for (auto& str : preloadModuleStrings) { - if (str.startsWith("file://"_s)) { - WTF::URL urlObject = WTF::URL(str); - if (!urlObject.isValid()) { - return Exception { TypeError, makeString("Invalid file URL: \""_s, str, '"') }; - } - // Replace in-place so the storage outlives the BunString borrow below. - str = urlObject.fileSystemPath(); - } - preloadModules.append(Bun::toString(str)); - } - - // try to ensure the cast from String* to StringImpl** is sane - static_assert(sizeof(WTF::String) == sizeof(WTF::StringImpl*)); - std::span execArgv = worker->m_options.execArgv - .transform([](Vector& vec) -> std::span { - return { reinterpret_cast(vec.begin()), vec.size() }; - }) - .value_or(std::span {}); - - // Take the worker-thread-held ref BEFORE spawning. The spawned thread will - // eventually call dispatchExit(), whose posted task (running back on THIS - // thread) drops this ref. If creation fails below we drop it ourselves. - worker->ref(); - - void* impl = WebWorker__create( - worker.ptr(), - bunVM(context.jsGlobalObject()), - nameStr, - urlStr, - &errorMessage, - static_cast(worker->m_parentContextId), - static_cast(worker->m_clientIdentifier), - worker->m_options.mini, - worker->m_options.unref, - worker->m_options.evalMode, - reinterpret_cast(worker->m_options.argv.begin()), - worker->m_options.argv.size(), - !worker->m_options.execArgv.has_value(), - execArgv.data(), - execArgv.size(), - preloadModules.begin(), - preloadModules.size()); - - preloadModuleStrings.clear(); - - if (!impl) { - worker->m_state.store(State::Closed); - worker->deref(); // undo the thread-held ref above - return Exception { TypeError, errorMessage.toWTFString(BunString::ZeroCopy) }; + url = urlObject.fileSystemPath(); } - // Parent-thread-only field; the close task can't run until we return to - // the event loop, so it's safe to set after the thread has been spawned. - worker->impl_ = impl; + auto worker = adoptRef(*new Worker(context, WTF::move(options))); + worker->suspendIfNeeded(); + auto started = worker->m_contextProxy->startWorkerGlobalScope(url); + if (started.hasException()) + return started.releaseException(); return worker; } Worker::~Worker() { - if (impl_) { - WebWorker__destroy(impl_); - } -} - -bool Worker::postTaskToParent(Function&& task) -{ - // By stable identifier, not pointer — postTaskTo locks the global map and - // returns false if the parent context is gone. Safe from any thread. - return ScriptExecutionContext::postTaskTo(m_parentContextId, WTF::move(task)); + m_contextProxy->workerObjectDestroyed(); } -// ---- Parent-thread API ------------------------------------------------------ - +// As in WebCore and Node: a message for a worker that has terminated is serialized (transfer +// side effects still happen) and then dropped by the proxy; it is not an error. ExceptionOr Worker::postMessage(JSC::JSGlobalObject& state, JSC::JSValue messageValue, StructuredSerializeOptions&& options) { - if (m_state.load() == State::Closed) - return Exception { InvalidStateError, "Worker has been terminated"_s }; - Vector> ports; auto serialized = SerializedScriptValue::create(state, messageValue, WTF::move(options.transfer), ports, SerializationForStorage::No, SerializationContext::WorkerPostMessage); if (serialized.hasException()) return serialized.releaseException(); - ExceptionOr> disentangledPorts = MessagePort::disentanglePorts(WTF::move(ports)); - if (disentangledPorts.hasException()) { + auto disentangledPorts = MessagePort::disentanglePorts(WTF::move(ports)); + if (disentangledPorts.hasException()) return disentangledPorts.releaseException(); - } - enqueueToWorker(MessageWithMessagePorts { serialized.releaseReturnValue(), disentangledPorts.releaseReturnValue() }); + m_contextProxy->postMessageToWorkerGlobalScope(MessageWithMessagePorts { serialized.releaseReturnValue(), disentangledPorts.releaseReturnValue() }); return {}; } -void Worker::enqueueToWorker(MessageWithMessagePorts&& message) -{ - { - Locker locker { m_toWorker.lock }; - m_toWorker.queue.append(WTF::move(message)); - // If the worker isn't Running yet, just buffer; fireEarlyMessages() - // drains the inbox on the worker thread once it is. If Closing/ - // Closed, also buffer (dropped with the Worker) — postMessage() - // already rejects on Closed, so only the close-handler window lands - // here. If a drain is already scheduled, don't double-schedule. - // drainScheduled is only set/cleared under the lock so the - // load/store pair is not a race. - if (m_state.load() != State::Running || m_toWorker.drainScheduled.load(std::memory_order_relaxed)) - return; - m_toWorker.drainScheduled.store(true, std::memory_order_relaxed); - } - bool posted = ScriptExecutionContext::postTaskTo(m_clientIdentifier, [protectedThis = Ref { *this }](ScriptExecutionContext& context) { - protectedThis->drainToWorker(context); - }); - if (!posted) { - Locker locker { m_toWorker.lock }; - m_toWorker.drainScheduled.store(false, std::memory_order_relaxed); - } -} - -void Worker::enqueueToParent(MessageWithMessagePorts&& message) -{ - { - Locker locker { m_toParent.lock }; - m_toParent.queue.append(WTF::move(message)); - if (m_toParent.drainScheduled.load(std::memory_order_relaxed)) - return; - m_toParent.drainScheduled.store(true, std::memory_order_relaxed); - } - // By stable identifier — this runs on the worker thread, so don't touch - // the parent's ScriptExecutionContext pointer directly. - bool posted = postTaskToParent([protectedThis = Ref { *this }](ScriptExecutionContext& context) { - protectedThis->drainToParent(context); - }); - if (!posted) { - Locker locker { m_toParent.lock }; - m_toParent.drainScheduled.store(false, std::memory_order_relaxed); - } -} - -// Shared drain loop for the two inboxes. Mirrors MessagePortPipe's -// drainAndDispatch (and Node's MessagePort::OnMessage): one task drains up to -// max(initial queue size, 1000) messages, running microtasks between each so -// queueMicrotask/Promise callbacks observe messages one at a time, then -// yields and reschedules if more remain. -// -// Unlike MessagePortPipe, Worker sides never transfer, so we don't need to -// re-check port identity each iteration — which lets us swap the whole inbox -// into a local deque under the lock and dispatch without contending with the -// sender. A sustained producer (e.g. a tight postMessage loop) would otherwise -// make every per-message pop a contended acquire. -template -static inline bool drainInbox(Worker::MessageInbox& inbox, Zig::GlobalObject* globalObject, ScriptExecutionContext& context, Dispatch&& dispatch) -{ - size_t limit; - Deque batch; - { - Locker locker { inbox.lock }; - if (inbox.queue.isEmpty()) { - inbox.drainScheduled.store(false, std::memory_order_relaxed); - return false; - } - limit = std::max(inbox.queue.size(), 1000); - batch = std::exchange(inbox.queue, {}); - } - - while (true) { - while (!batch.isEmpty()) { - if (limit-- == 0) { - // Yield to the rest of the event loop. Return the undrained - // tail to the front of the inbox so it stays ahead of - // anything enqueued concurrently; caller reschedules. - Locker locker { inbox.lock }; - while (!batch.isEmpty()) - inbox.queue.prepend(batch.takeLast()); - return true; - } - auto message = batch.takeFirst(); - - auto ports = MessagePort::entanglePorts(context, WTF::move(message.transferredPorts)); - auto event = MessageEvent::create(*context.jsGlobalObject(), message.message.releaseNonNull(), nullptr, WTF::move(ports)); - dispatch(event.event); - - if (globalObject->drainMicrotasks()) { - // Termination pending. Drop the rest — dispatch is a no-op - // once m_terminateRequested is set (drainToParent), and the - // worker thread is tearing down (drainToWorker). - return false; - } - } - - // Batch exhausted — see if more arrived while we were dispatching. - Locker locker { inbox.lock }; - if (inbox.queue.isEmpty()) { - inbox.drainScheduled.store(false, std::memory_order_relaxed); - return false; - } - if (limit == 0) - return true; // budget spent; caller reschedules - batch = std::exchange(inbox.queue, {}); - } -} - -void Worker::drainToWorker(ScriptExecutionContext& context) +void Worker::terminate() { - auto* globalObject = uncheckedDowncast(context.jsGlobalObject()); - if (!globalObject) { - Locker locker { m_toWorker.lock }; - m_toWorker.drainScheduled.store(false, std::memory_order_relaxed); - return; - } - bool reschedule = drainInbox(m_toWorker, globalObject, context, [&](Event& event) { - globalObject->globalEventScope->dispatchEvent(event); - }); - if (reschedule) { - ScriptExecutionContext::postTaskTo(m_clientIdentifier, [protectedThis = Ref { *this }](ScriptExecutionContext& ctx) { - protectedThis->drainToWorker(ctx); - }); - } + m_wasTerminated = true; + m_contextProxy->terminateWorkerGlobalScope(); } -void Worker::drainToParent(ScriptExecutionContext& context) +void Worker::stop() { - auto* globalObject = defaultGlobalObject(context.jsGlobalObject()); - if (!globalObject) { - Locker locker { m_toParent.lock }; - m_toParent.drainScheduled.store(false, std::memory_order_relaxed); - return; - } - bool reschedule = drainInbox(m_toParent, globalObject, context, [&](Event& event) { - dispatchEvent(event); - }); - if (reschedule) { - postTaskToParent([protectedThis = Ref { *this }](ScriptExecutionContext& c) { - protectedThis->drainToParent(c); - }); - } + terminate(); } -void Worker::terminate() +bool Worker::virtualHasPendingActivity() const { - if (m_terminateRequested.exchange(true)) - return; - WebWorker__notifyNeedTermination(impl_); + return m_contextProxy->hasPendingActivity(); } void Worker::setKeepAlive(bool keepAlive) { - // Once terminate() has been called or the close task has started, the - // worker no longer participates in the parent's liveness — the close - // task is the last thing to touch parent_poll_ref. - if (m_terminateRequested.load() || m_state.load() >= State::Closing) - return; - WebWorker__setRef(impl_, keepAlive); + m_contextProxy->setKeepAlive(keepAlive); } void Worker::dispatchEvent(Event& event) { - // Suppress user-visible events once terminate() has been called or the - // worker has closed. The close event itself bypasses this (dispatchExit - // calls EventTargetWithInlineData::dispatchEvent directly) so that - // `await worker.terminate()` still resolves. - if (m_terminateRequested.load() || m_state.load() == State::Closed) + if (m_wasTerminated || !m_contextProxy->hasPendingActivity()) return; EventTargetWithInlineData::dispatchEvent(event); } -bool Worker::postTaskToWorkerGlobalScope(Function&& task) -{ - { - Locker lock(m_pendingTasksMutex); - switch (m_state.load()) { - case State::Pending: - // Worker VM not up yet; queue for fireEarlyMessages(). - m_pendingTasks.append(WTF::move(task)); - return true; - case State::Running: - break; - case State::Closing: - case State::Closed: - // Worker VM is gone; drop immediately (silent no-op). - // postMessage() goes through enqueueToWorker(), not here — the - // only user is getHeapSnapshot(). - return false; - } - } - return ScriptExecutionContext::postTaskTo(m_clientIdentifier, WTF::move(task)); -} - -uint64_t Worker::registerCrossVMRequest(JSC::VM& vm, JSC::JSPromise* promise) -{ - uint64_t id = m_nextRequestId.fetch_add(1); - Locker lock(m_pendingTasksMutex); - m_pendingCrossVMRequests.add(id, JSC::Strong(vm, promise)); - return id; -} - -JSC::Strong Worker::takeCrossVMRequest(uint64_t id) -{ - Locker lock(m_pendingTasksMutex); - return m_pendingCrossVMRequests.take(id); -} - -void Worker::rejectAllCrossVMRequests(JSC::JSGlobalObject* globalObject) -{ - HashMap> pending; - { - Locker lock(m_pendingTasksMutex); - pending = std::exchange(m_pendingCrossVMRequests, {}); - } - if (pending.isEmpty()) - return; - auto& vm = JSC::getVM(globalObject); - for (auto& entry : pending) - entry.value->reject(vm, Bun::createError(defaultGlobalObject(globalObject), Bun::ErrorCode::ERR_WORKER_NOT_RUNNING, "Worker instance not running"_s)); -} - -// ---- Worker-thread entry points --------------------------------------------- - -void Worker::dispatchOnline(Zig::GlobalObject* workerGlobalObject) -{ - // Pending→Running under the same lock postTaskToWorkerGlobalScope uses, so - // a message post racing this transition either queues (drained below by - // fireEarlyMessages) or posts directly — never both, never neither. - // - // This MUST happen BEFORE the open event is posted to the parent: the - // parent's `online` handler may immediately call getHeapSnapshot() (or - // anything else gated on isOnline() / postTaskToWorkerGlobalScope()). If - // the state flip happens after the post, a fast parent thread can run the - // open task while m_state is still Pending and observe - // ERR_WORKER_NOT_RUNNING — flaky `await once(worker, "online"); - // worker.getHeapSnapshot()` in worker_threads.test.ts. - { - Locker lock(m_pendingTasksMutex); - m_state.store(State::Running); - } - - postTaskToParent([protectedThis = Ref { *this }](ScriptExecutionContext&) { - if (protectedThis->hasEventListeners(eventNames().openEvent)) { - auto event = Event::create(eventNames().openEvent, Event::CanBubble::No, Event::IsCancelable::No); - protectedThis->dispatchEvent(event); - } - }); - - auto* thisContext = workerGlobalObject->scriptExecutionContext(); - if (!thisContext) { - return; - } - RELEASE_ASSERT(&thisContext->vm() == &workerGlobalObject->vm()); - RELEASE_ASSERT(thisContext == workerGlobalObject->globalEventScope->scriptExecutionContext()); -} - -// Kick off the first drain of messages that arrived before the worker was -// online. A parent enqueue that observed State::Running (set in -// dispatchOnline, which runs just before fireEarlyMessages) may have already -// scheduled one — drainScheduled, set under the inbox lock, arbitrates. -static inline void workerScheduleInitialDrain(Worker& worker, Worker::MessageInbox& inbox, ScriptExecutionContext& ctx) -{ - { - Locker locker { inbox.lock }; - if (inbox.queue.isEmpty() || inbox.drainScheduled.load(std::memory_order_relaxed)) - return; - inbox.drainScheduled.store(true, std::memory_order_relaxed); - } - worker.drainToWorker(ctx); -} - -void Worker::fireEarlyMessages(Zig::GlobalObject* workerGlobalObject) +void Worker::dispatchCloseEvent(Event& event) { - auto tasks = [&]() { - Locker lock(m_pendingTasksMutex); - return std::exchange(m_pendingTasks, {}); - }(); - auto* thisContext = workerGlobalObject->scriptExecutionContext(); - - if (workerGlobalObject->globalEventScope->hasActiveEventListeners(eventNames().messageEvent)) { - for (auto& task : tasks) { - task(*thisContext); - } - workerScheduleInitialDrain(*this, m_toWorker, *thisContext); - } else { - thisContext->postTask([tasks = WTF::move(tasks), protectedThis = Ref { *this }](auto& ctx) mutable { - for (auto& task : tasks) { - task(ctx); - } - workerScheduleInitialDrain(protectedThis.get(), protectedThis->m_toWorker, ctx); - }); - } -} - -void Worker::dispatchErrorWithMessage(WTF::String message) -{ - postTaskToParent([protectedThis = Ref { *this }, message = message.isolatedCopy()](ScriptExecutionContext&) { - ErrorEvent::Init init; - init.message = message; - - auto event = ErrorEvent::create(eventNames().errorEvent, init, EventIsTrusted::Yes); - protectedThis->dispatchEvent(event); - }); -} - -bool Worker::dispatchErrorWithValue(Zig::GlobalObject* workerGlobalObject, JSValue value) -{ - // This is the top of the stack for the worker's error dispatch: both the - // structured clone below (even in NonThrowing mode, serialization can run - // JS via getters/proxies and leave a pending exception) and the `code` - // property read must not propagate exceptions out of this function. - auto& vm = JSC::getVM(workerGlobalObject); - auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - - auto serialized = SerializedScriptValue::create(*workerGlobalObject, value, SerializationForStorage::No, SerializationErrorMode::NonThrowing); - CLEAR_IF_EXCEPTION(scope); - if (!serialized) - return false; - - // Structured clone keeps only the standard Error fields - // (name/message/stack/line/column/sourceURL), but Node's worker 'error' - // event preserves `error.code` (lib/internal/error_serdes.js) and the - // vendored node tests assert on it (e.g. ERR_TRACE_EVENTS_UNAVAILABLE). - // Carry a string `code` across the thread boundary manually. If reading - // `code` throws (a throwing getter/proxy), drop the code and proceed. - String errorCode; - if (value.isObject() && !scope.exception()) { - JSValue codeValue = value.getObject()->getIfPropertyExists(workerGlobalObject, WebCore::builtinNames(vm).codePublicName()); - if (!scope.exception() && codeValue && codeValue.isString()) - errorCode = codeValue.toWTFString(workerGlobalObject); - CLEAR_IF_EXCEPTION(scope); - } - - return postTaskToParent([protectedThis = Ref { *this }, serialized, errorCode = WTF::move(errorCode).isolatedCopy()](ScriptExecutionContext& context) { - auto* globalObject = context.globalObject(); - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - ErrorEvent::Init init; - JSValue deserialized = serialized->deserialize(*globalObject, globalObject, SerializationErrorMode::NonThrowing); - RETURN_IF_EXCEPTION(scope, ); - if (!errorCode.isNull()) { - if (auto* errorObject = deserialized.getObject()) - errorObject->putDirect(vm, WebCore::builtinNames(vm).codePublicName(), JSC::jsString(vm, errorCode)); - } - init.error = deserialized; - - auto event = ErrorEvent::create(eventNames().errorEvent, init, EventIsTrusted::Yes); - protectedThis->dispatchEvent(event); - }); + EventTargetWithInlineData::dispatchEvent(event); } -bool Worker::dispatchExit(int32_t exitCode) -{ - // Runs on the worker thread after its JSC VM has been torn down. Post the - // close event to the parent; that task additionally releases parent_poll_ref - // (parent-thread-only). - // - // If posting fails — parent context no longer exists (nested worker whose - // middle thread has already torn down) — the ref and poll are intentionally - // leaked: dropping the ref here would run ~Worker → ~EventTarget on the - // worker thread and trip EventListenerMap's single-thread assert. Parent - // teardown implies process shutdown (or at least that nothing observes the - // leak), so this is bounded. The proper fix is for a worker to stop+join - // its sub-workers before tearing down its own context. - // - // The create-time ref (taken in create() to keep `this` alive while the - // worker thread runs) is released via the `betweenLookupAndEnqueue` hook — - // i.e. after the parent context is found-live and the lambda's captured - // `Ref` exists, but BEFORE the task is enqueued. Once enqueued the parent - // can run-and-destroy the lambda (dropping `protectedThis`) and sweep the - // JSWorker before this frame resumes; deref()ing after that point can be - // the last ref and run ~Worker on the worker thread (the 71d7f78f5e74 - // race). Releasing inside the lambda body instead would re-introduce the - // shutdown leak that commit closed: when global_exit() destroys queued - // close tasks without running them, the inner deref() never fires and the - // WebWorker box leaks. - return ScriptExecutionContext::postTaskTo( - m_parentContextId, - [this] { this->deref(); }, - [exitCode, protectedThis = Ref { *this }](ScriptExecutionContext&) { - // Closing → dispatch 'close' → Closed. The split lets 'close'/'exit' - // handlers observe threadId == -1 and isOnline() == false while - // postMessage() (gated only on Closed) still accepts and drops the - // message, matching browser/Node and pre-refactor behaviour. - // - // Drop any tasks queued while the worker was Pending and never - // reached Running (m_pendingCrossVMRequests / rejectAllCrossVMRequests - // settles the callers' promises + frees the parent-VM Strong<>). - // Take the queue under the same lock that flips m_state so a racing - // postTaskToWorkerGlobalScope either lands in the cleared queue or - // sees Closing and returns false. - { - Locker lock(protectedThis->m_pendingTasksMutex); - protectedThis->m_state.store(State::Closing); - protectedThis->m_pendingTasks.clear(); - } - // Reject any introspection promises whose round-trip never completed. - if (auto* ctx = protectedThis->scriptExecutionContext()) - protectedThis->rejectAllCrossVMRequests(ctx->globalObject()); +// ---- Worker-thread side: hooks the native thread object calls, and the script-facing functions that +// run inside a worker (parentPort.postMessage, workerData, receiveMessageOnPort, ...). - if (protectedThis->hasEventListeners(eventNames().closeEvent)) { - auto event = CloseEvent::create(exitCode == 0, static_cast(exitCode), exitCode == 0 ? "Worker terminated normally"_s : "Worker exited abnormally"_s); - protectedThis->EventTargetWithInlineData::dispatchEvent(event); - } +// The proxy of the worker whose global scope runs on `bunVM`'s thread, or null on the main thread. +extern "C" WorkerMessagingProxy* WebWorker__getMessagingProxy(void* bunVM); - protectedThis->m_state.store(State::Closed); - WebWorker__releaseParentPollRef(protectedThis->impl_); - // protectedThis (and the JSWorker GC cell, if still rooted) keep us - // alive across the close-event dispatch; both deref on the parent - // thread (lambda destruction here / GC sweep), so ~Worker never runs - // on the worker thread. - }); -} - -// ---- extern "C" shims (called from native code) ------------------------------ - -extern "C" void WebWorker__teardownJSCVM(Zig::GlobalObject* globalObject) -{ - auto& vm = JSC::getVM(globalObject); - vm.setHasTerminationRequest(); - // Mark the context permanently terminating so postTaskTo drops tasks that - // can never run (e.g. notifyPeerClosed posted during the final collectNow). - if (auto* ctx = globalObject->scriptExecutionContext()) - ctx->markTerminating(); - // Same for DeferredWorkTimer: collectNow -> finalizers and ~VM -> - // WaiterListManager::unregister both reach scheduleWorkSoon; past this - // point those calls must not enqueue into our drained concurrent queue. - if (auto* clientData = WebCore::clientData(vm)) - clientData->deferredWorkTimer.markShuttingDown(); - - { - auto scope = DECLARE_THROW_SCOPE(vm); - { - auto* moduleLoader = globalObject->moduleLoader(); - // JSModuleLoader::visitChildrenImpl iterates these maps on the GC - // thread under cellLock(); take the same lock so clearing them - // can't race a concurrent marker. - WTF::Locker locker { moduleLoader->cellLock() }; - moduleLoader->clearAll(); - } - globalObject->requireMap()->clear(globalObject); - scope.exception(); // TODO: handle or assert none? - vm.deleteAllCode(JSC::DeleteAllCodeEffort::PreventCollectionAndDeleteAllCode); - gcUnprotect(globalObject); - globalObject = nullptr; - } - - vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); - - // Drop the single ref taken by `Zig__GlobalObject__create` - // (`vmPtr->refSuppressingSaferCPPChecking()`), bringing the VM refcount - // to zero — `~VM` runs here while the API lock is still held by this - // thread. The worker thread acquires the API lock manually with no - // extra VM ref (see `WebWorker::thread_main`), so a second `deref` would - // run `~VM` twice / dereference the freed VM. - vm.derefSuppressingSaferCPPChecking(); // NOLINT -} - -extern "C" void WebWorker__dispatchExit(Worker* worker, int32_t exitCode) -{ - worker->dispatchExit(exitCode); -} - -// The entry module just finished (or failed) its top-level evaluation. Flush -// the worker_threads hub's deferred cross-thread deliveries: node's bootstrap -// runs the synchronous CJS main before any port delivery, so a routed message -// must not observe "no listeners" while the entry that registers them is still -// loading. Called from spin() on EVERY post-evaluation path (including entry -// throw / TLA reject / TLA unsettled) so a buffered postMessageToThread never +// The entry module just finished (or failed) its top-level evaluation. Flush the worker_threads +// hub's deferred cross-thread deliveries: node's bootstrap runs the synchronous CJS main before any +// port delivery, so a routed message must not observe "no listeners" while the entry that registers +// them is still loading. Runs on every post-evaluation path so a buffered postMessageToThread never // leaves its sender's Atomics.waitAsync unresolved. extern "C" void WebWorker__entrySettled(Zig::GlobalObject* globalObject) { + // parentPort starts delivering now (whatever the parent posted meanwhile is buffered in the pipe). + globalObject->nodeWorkerEntryDidSettle(); auto* hook = globalObject->nodeWorkerEntryEvaluatedHook(); if (!hook) return; globalObject->setNodeWorkerEntryEvaluatedHook(nullptr); auto& vm = JSC::getVM(globalObject); - // On failure paths (entry threw / TLA rejected) an exception may already be - // pending; the hook itself can't observe it and shutdown will report/discard - // it either way, so clear it here so JSC::call doesn't assert. On the success - // path scope.exception() is null and this is a no-op. + // On failure paths (entry threw / TLA rejected) an exception may already be pending; the hook + // can't observe it and shutdown reports it either way. auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); CLEAR_IF_EXCEPTION(scope); if (vm.hasPendingTerminationException()) @@ -713,41 +170,38 @@ extern "C" void WebWorker__entrySettled(Zig::GlobalObject* globalObject) CLEAR_IF_EXCEPTION(scope); } -extern "C" void WebWorker__dispatchOnline(Worker* worker, Zig::GlobalObject* globalObject) +extern "C" void WebWorker__workerGlobalScopeStarted(WorkerMessagingProxy* proxy, Zig::GlobalObject* globalObject) { WebWorker__entrySettled(globalObject); - worker->dispatchOnline(globalObject); + proxy->workerGlobalScopeStarted(*globalObject); +} + +extern "C" void WebWorker__workerGlobalScopeDestroyed(WorkerMessagingProxy* proxy, int32_t exitCode, bool stoppedByParent) +{ + proxy->workerGlobalScopeDestroyed(exitCode, stoppedByParent); } -extern "C" void WebWorker__fireEarlyMessages(Worker* worker, Zig::GlobalObject* globalObject) +extern "C" void WebWorker__parentContextWillDestroy(WorkerMessagingProxy* proxy) { - worker->fireEarlyMessages(globalObject); + proxy->parentContextWillDestroy(); } -extern "C" void WebWorker__dispatchError(Zig::GlobalObject* globalObject, Worker* worker, BunString* message, JSC::EncodedJSValue errorValue) +// An uncaught error inside the worker: dispatch 'error' on the worker's own global scope, then report +// it to the Worker object. +extern "C" void WebWorker__dispatchError(Zig::GlobalObject* globalObject, WorkerMessagingProxy* proxy, BunString* message, JSC::EncodedJSValue errorValue) { - JSValue error = JSC::JSValue::decode(errorValue); - WTF::String messageStr = message->transferToWTFString(); + JSC::JSValue error = JSC::JSValue::decode(errorValue); + String messageStr = message->transferToWTFString(); ErrorEvent::Init init; - init.message = messageStr.isolatedCopy(); + init.message = messageStr; init.error = error; init.cancelable = false; init.bubbles = false; - globalObject->globalEventScope->dispatchEvent(ErrorEvent::create(eventNames().errorEvent, init, EventIsTrusted::Yes)); - switch (worker->options().kind) { - case WorkerOptions::Kind::Web: - return worker->dispatchErrorWithMessage(WTF::move(messageStr)); - case WorkerOptions::Kind::Node: - if (!worker->dispatchErrorWithValue(globalObject, error)) { - // If serialization threw an error, use the string instead - worker->dispatchErrorWithMessage(WTF::move(messageStr)); - } - return; - } + proxy->postErrorToWorkerObject(*globalObject, messageStr, error); } -extern "C" WebCore::Worker* WebWorker__getParentWorker(void* bunVM); +JSC_DECLARE_HOST_FUNCTION(jsFunctionSetParentPort); JSC_DEFINE_HOST_FUNCTION(jsReceiveMessageOnPort, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) { @@ -766,7 +220,16 @@ JSC_DEFINE_HOST_FUNCTION(jsReceiveMessageOnPort, (JSGlobalObject * lexicalGlobal } if (auto* messagePort = dynamicDowncast(port)) { - RELEASE_AND_RETURN(scope, JSC::JSValue::encode(messagePort->wrapped().tryTakeMessage(lexicalGlobalObject))); + // node: `undefined` when the queue is empty, otherwise `{ message }` — built + // here so a posted `undefined`/falsy value is distinguishable from "empty". + bool hadMessage = false; + JSValue message = messagePort->wrapped().tryTakeMessage(lexicalGlobalObject, hadMessage); + RETURN_IF_EXCEPTION(scope, {}); + if (!hadMessage) + return JSC::JSValue::encode(jsUndefined()); + auto* result = JSC::constructEmptyObject(lexicalGlobalObject, lexicalGlobalObject->objectPrototype(), 1); + result->putDirect(vm, JSC::Identifier::fromString(vm, "message"_s), message); + return JSC::JSValue::encode(result); } else if (dynamicDowncast(port)) { // TODO: support broadcast channels return JSC::JSValue::encode(jsUndefined()); @@ -835,9 +298,10 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) JSValue threadName = jsEmptyString(vm); JSMap* environmentData = nullptr; - if (auto* worker = WebWorker__getParentWorker(globalObject->bunVM())) { - auto& options = worker->options(); - auto ports = MessagePort::entanglePorts(*ScriptExecutionContext::getScriptExecutionContext(worker->clientIdentifier()), WTF::move(options.dataMessagePorts)); + auto* proxy = WebWorker__getMessagingProxy(globalObject->bunVM()); + if (proxy) { + auto& options = proxy->options(); + auto ports = MessagePort::entanglePorts(*globalObject->scriptExecutionContext(), WTF::move(options.dataMessagePorts)); RefPtr serialized = WTF::move(options.workerDataAndEnvironmentData); // `workerDataAndEnvironmentData` is moved-from on the first call. If // this binding is created twice (lazy-init re-entry), `serialized` is @@ -865,7 +329,7 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) } // Main thread starts at 1 - threadId = jsNumber(worker->clientIdentifier() - 1); + threadId = jsNumber(proxy->workerContextIdentifier() - 1); // isolatedCopy: this JSString lives in the worker heap; it must own a // worker-local impl so its GC deref never races m_options.name's // (non-atomic) refcount on the parent thread. @@ -878,11 +342,9 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) ASSERT(environmentData); globalObject->setNodeWorkerEnvironmentData(environmentData); - bool isNodeWorker = false; - if (auto* worker = WebWorker__getParentWorker(globalObject->bunVM())) - isNodeWorker = worker->options().kind == WorkerOptions::Kind::Node; + bool isNodeWorker = proxy && proxy->options().kind == WorkerOptions::Kind::Node; - JSObject* array = constructEmptyArray(globalObject, nullptr, 11); + JSObject* array = constructEmptyArray(globalObject, nullptr, 12); RETURN_IF_EXCEPTION(scope, {}); array->putDirectIndex(globalObject, 0, workerData); array->putDirectIndex(globalObject, 1, threadId); @@ -895,9 +357,21 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) array->putDirectIndex(globalObject, 8, JSFunction::create(vm, globalObject, 1, "markAsUncloneable"_s, jsFunctionMarkAsUncloneable, ImplementationVisibility::Public, NoIntrinsic)); array->putDirectIndex(globalObject, 9, JSFunction::create(vm, globalObject, 1, "setEntryEvaluatedHook"_s, jsFunctionSetEntryEvaluatedHook, ImplementationVisibility::Public, NoIntrinsic)); array->putDirectIndex(globalObject, 10, jsBoolean(isNodeWorker)); + array->putDirectIndex(globalObject, 11, JSFunction::create(vm, globalObject, 1, "setParentPort"_s, jsFunctionSetParentPort, ImplementationVisibility::Public, NoIntrinsic)); return array; } +// worker_threads (worker side): register the transferred port as this thread's parentPort. +JSC_DEFINE_HOST_FUNCTION(jsFunctionSetParentPort, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto* port = dynamicDowncast(callFrame->argument(0)); + if (!port) + return JSValue::encode(jsUndefined()); + globalObject->setNodeParentPort(&port->wrapped()); + return JSValue::encode(jsUndefined()); +} + JSC_DEFINE_HOST_FUNCTION(jsFunctionPostMessage, (JSC::JSGlobalObject * leixcalGlobalObject, JSC::CallFrame* callFrame)) { @@ -908,8 +382,8 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionPostMessage, if (!globalObject) [[unlikely]] return JSValue::encode(jsUndefined()); - Worker* worker = WebWorker__getParentWorker(globalObject->bunVM()); - if (worker == nullptr) + auto* proxy = WebWorker__getMessagingProxy(globalObject->bunVM()); + if (!proxy) return JSValue::encode(jsUndefined()); JSC::JSValue value = callFrame->argument(0); @@ -939,16 +413,16 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionPostMessage, WebCore::propagateException(*globalObject, scope, serialized.releaseException()); RELEASE_AND_RETURN(scope, {}); } - scope.assertNoException(); + RETURN_IF_EXCEPTION(scope, {}); ExceptionOr> disentangledPorts = MessagePort::disentanglePorts(WTF::move(ports)); if (disentangledPorts.hasException()) { WebCore::propagateException(*globalObject, scope, disentangledPorts.releaseException()); RELEASE_AND_RETURN(scope, {}); } - scope.assertNoException(); + RETURN_IF_EXCEPTION(scope, {}); - worker->enqueueToParent(MessageWithMessagePorts { serialized.releaseReturnValue(), disentangledPorts.releaseReturnValue() }); + proxy->postMessageToWorkerObject(MessageWithMessagePorts { serialized.releaseReturnValue(), disentangledPorts.releaseReturnValue() }); return JSValue::encode(jsUndefined()); } diff --git a/src/jsc/bindings/webcore/Worker.h b/src/jsc/bindings/webcore/Worker.h index 0e2abc602ab3..eaf0f3fcd957 100644 --- a/src/jsc/bindings/webcore/Worker.h +++ b/src/jsc/bindings/webcore/Worker.h @@ -25,22 +25,15 @@ #pragma once +#include "ActiveDOMObject.h" #include "EventTarget.h" -#include "MessageWithMessagePorts.h" -#include "WorkerOptions.h" -#include -#include -#include -#include -#include -#include "ContextDestructionObserver.h" -#include "Event.h" +#include "ExceptionOr.h" +#include "WorkerMessagingProxy.h" +#include namespace JSC { -class CallFrame; -class JSObject; +class JSGlobalObject; class JSValue; -class JSPromise; } namespace WebCore { @@ -49,118 +42,40 @@ class ScriptExecutionContext; struct StructuredSerializeOptions; struct WorkerOptions; -/// Parent-side handle for a Web or Node worker thread. -/// -/// Lifetime / ownership (see also the header comment in src/jsc/web_worker.rs): -/// -/// JSWorker (GC'd JSCell) ──Ref──► Worker ──owns──► native WebWorker -/// parent thread ThreadSafeRefCounted default_allocator -/// -/// Refs held on this object: -/// - JSWorker wrapper from construct() until GC finalize -/// - worker thread taken in create() before the thread is spawned; -/// dropped on the PARENT thread inside dispatchExit()'s -/// posted close task, so ~Worker never runs on the -/// worker thread (EventListenerMap is single-threaded) -/// - transient Ref{*this} captured by posted tasks -/// -/// impl_ (native WebWorker*) is owned by this object and freed in ~Worker(), so -/// terminate()/ref()/unref() can never see a dangling pointer while JS holds -/// the wrapper. -/// -/// State machine: -/// -/// ┌────────┐ dispatchOnline ┌────────┐ -/// │Pending │ ────────────────► │Running │ -/// └───┬────┘ (worker thread, └───┬────┘ -/// │ under lock) │ -/// │ │ -/// └────────────┬───────────────┘ -/// │ close task (parent thread) -/// ▼ -/// ┌────────┐ 'close' event ┌────────┐ -/// │Closing │ ───────────────► │ Closed │ -/// └────────┘ dispatched └────────┘ -/// -/// Closing exists so that inside the 'close'/'exit' handler threadId reads -/// -1 and isOnline() is false (old ClosingFlag behaviour) while postMessage() -/// — which only gates on Closed (old TerminatedFlag behaviour) — still -/// accepts and silently drops the message, matching browser/Node semantics. -/// -/// m_terminateRequested is orthogonal: set once by terminate(), gates -/// dispatchEvent()/setKeepAlive(), and is mirrored into the native side via -/// WebWorker__notifyNeedTermination so the worker loop can observe it. -class Worker final : public ThreadSafeRefCounted, public EventTargetWithInlineData, private ContextDestructionObserver { +// The script-visible Worker object. Lives entirely on the thread that constructed it; everything +// that involves the worker thread goes through m_contextProxy. +class Worker final : public RefCounted, public EventTargetWithInlineData, public ActiveDOMObject { WTF_MAKE_TZONE_ALLOCATED(Worker); public: - enum class State : uint8_t { - Pending, // created; worker thread starting up - Running, // dispatchOnline has fired; worker event loop is spinning - Closing, // worker thread has exited; close task is dispatching the 'close' event - Closed, // close event dispatched on the parent; worker is fully done - }; - static ExceptionOr> create(ScriptExecutionContext&, const String& url, WorkerOptions&&); ~Worker(); - ExceptionOr postMessage(JSC::JSGlobalObject&, JSC::JSValue message, StructuredSerializeOptions&&); - - using ThreadSafeRefCounted::deref; - using ThreadSafeRefCounted::ref; + // ActiveDOMObject. + void ref() const final { RefCounted::ref(); } + void deref() const final { RefCounted::deref(); } + USING_CAN_MAKE_WEAKPTR(EventTargetWithInlineData); - // -- Parent-thread API (called from JS on the owning thread) ------------- + ExceptionOr postMessage(JSC::JSGlobalObject&, JSC::JSValue message, StructuredSerializeOptions&&); void terminate(); + // terminate() was called or the thread has gone; the object dispatches nothing further. + bool wasTerminated() const { return m_wasTerminated || m_contextProxy->isClosingOrClosed(); } + // The thread has exited (or never started). threadId reads -1 from here on, as in Node. + bool hasExited() const { return m_contextProxy->isClosingOrClosed(); } + bool isOnline() const { return m_contextProxy->isOnline(); } void setKeepAlive(bool); - void dispatchEvent(Event&); - // Returns true if the task was accepted (queued to Pending or posted to - // Running). Returns false if the worker is Closing/Closed or its context - // is already gone — the caller must handle cleanup itself. - bool postTaskToWorkerGlobalScope(Function&&); - - // -- State queries (safe from any thread; all loads are atomic) ---------- - bool wasTerminated() const { return m_state.load() >= State::Closing; } - bool hasPendingActivity() const { return m_state.load() != State::Closed; } - bool isOnline() const { return m_state.load() == State::Running; } - - const String& name() const { return m_options.name; } - ScriptExecutionContext* scriptExecutionContext() const final { return ContextDestructionObserver::scriptExecutionContext(); } - ScriptExecutionContextIdentifier clientIdentifier() const { return m_clientIdentifier; } - WorkerOptions& options() { return m_options; } - - // -- Worker-thread entry points (each posts to m_parentContextId) -------- - void dispatchOnline(Zig::GlobalObject* workerGlobalObject); - void fireEarlyMessages(Zig::GlobalObject* workerGlobalObject); - void dispatchErrorWithMessage(WTF::String message); - bool dispatchErrorWithValue(Zig::GlobalObject* workerGlobalObject, JSValue value); - bool dispatchExit(int32_t exitCode); - - // Post a task to the parent's ScriptExecutionContext by stable identifier. - // Returns false if the parent context no longer exists (nested worker whose - // middle thread has torn down). Callable from any thread. - bool postTaskToParent(Function&&); - - // Parent-thread registry for introspection promises (getHeapSnapshot etc). - // Captured by id across the cross-thread round-trip so the worker thread - // never touches the parent VM's HandleSet, and drained (rejected) by - // dispatchExit so a Running+terminate race settles instead of leaking. - uint64_t registerCrossVMRequest(JSC::VM&, JSC::JSPromise*); - JSC::Strong takeCrossVMRequest(uint64_t id); - void rejectAllCrossVMRequests(JSC::JSGlobalObject*); - - // Coalesced cross-thread inbox for worker↔parent postMessage, mirroring - // MessagePortPipe: a burst of N postMessage calls schedules one drain - // task on the receiver, which loops dispatching + draining microtasks. - // This avoids N× (global-contexts-lock + HashMap lookup + lambda alloc) - // per burst. - struct MessageInbox { - WTF::Lock lock; - WTF::Deque queue WTF_GUARDED_BY_LOCK(lock); - std::atomic drainScheduled { false }; - }; - - void enqueueToParent(MessageWithMessagePorts&&); - void drainToWorker(ScriptExecutionContext&); + + // Node worker_threads: 'message'/'error'/'messageerror' are not delivered once terminate() was + // called; 'close' (which carries the exit code) always is. + void dispatchEvent(Event&) final; + void dispatchCloseEvent(Event&); + + const String& name() const { return m_name; } + // Both identifiers are process-unique; threadId is derived from the worker's. + ScriptExecutionContextIdentifier clientIdentifier() const { return m_contextProxy->workerContextIdentifier(); } + WorkerMessagingProxy& contextProxy() { return m_contextProxy.get(); } + + ScriptExecutionContext* scriptExecutionContext() const final { return ActiveDOMObject::scriptExecutionContext(); } private: Worker(ScriptExecutionContext&, WorkerOptions&&); @@ -170,44 +85,16 @@ class Worker final : public ThreadSafeRefCounted, public EventTargetWith void derefEventTarget() final { deref(); } void eventListenersDidChange() final {}; - void enqueueToWorker(MessageWithMessagePorts&&); - void drainToParent(ScriptExecutionContext&); - - WorkerOptions m_options; - - // Messages posted before the worker reaches Running are queued here and - // flushed by fireEarlyMessages(). The Pending→Running transition happens - // under this lock so postTaskToWorkerGlobalScope never loses a task. If the - // worker never reaches Running (entry threw / failed to load / unsettled - // TLA), dispatchExit clears the queue on the parent thread and - // rejectAllCrossVMRequests() settles the callers' promises. - Lock m_pendingTasksMutex; - Deque> m_pendingTasks WTF_GUARDED_BY_LOCK(m_pendingTasksMutex); - // Owned by the parent thread; guarded only for take() vs reject-all ordering. - HashMap> m_pendingCrossVMRequests WTF_GUARDED_BY_LOCK(m_pendingTasksMutex); - std::atomic m_nextRequestId { 1 }; - - MessageInbox m_toWorker; // messages parent → worker, drained on the worker thread - MessageInbox m_toParent; // messages worker → parent, drained on the parent thread - - std::atomic m_state { State::Pending }; - std::atomic m_terminateRequested { false }; - - // Stable for the process lifetime; used with ScriptExecutionContext:: - // postTaskTo() so the worker thread never dereferences the parent context - // pointer (which could be freed concurrently). - const ScriptExecutionContextIdentifier m_parentContextId; - // This worker's own context identifier (allocated at construction, bound - // once the worker VM is up). - const ScriptExecutionContextIdentifier m_clientIdentifier; - - // Owned native WebWorker*. Written once in create(), read only on the parent - // thread (terminate/setKeepAlive) or in the close task (also parent thread). - // Freed in ~Worker(). Never null once create() returns successfully. - void* impl_ { nullptr }; + // ActiveDOMObject. + void stop() final; + bool virtualHasPendingActivity() const final; + + const String m_name; + const Ref m_contextProxy; + bool m_wasTerminated { false }; }; -JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject); +JSC::JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject); JSC_DECLARE_HOST_FUNCTION(jsFunctionPostMessage); diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp new file mode 100644 index 000000000000..926b113ba152 --- /dev/null +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp @@ -0,0 +1,564 @@ +/* + * Copyright (C) 2008-2017 Apple Inc. All rights reserved. + * Copyright (C) 2009 Google Inc. All Rights Reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "config.h" +#include "WorkerMessagingProxy.h" + +#include "BunClientData.h" +#include "GlobalEventScope.h" +#include "CloseEvent.h" +#include "ErrorCode.h" +#include "ErrorEvent.h" +#include "EventNames.h" +#include "MessageEvent.h" +#include "MessagePort.h" +#include "SerializedScriptValue.h" +#include "Worker.h" +#include "ZigGlobalObject.h" +#include + +namespace WebCore { + +WTF_MAKE_TZONE_ALLOCATED_IMPL(WorkerMessagingProxy); + +// ---- The native thread object (src/jsc/web_worker.rs) -------------------------------------------- +extern "C" { + +// Allocates the thread object holding one ref for the caller, takes the keep-alive on the parent +// event loop, and spawns the thread. Null (with errorMessage set) if nothing was started. +void* WebWorker__create( + WorkerMessagingProxy*, + void* parentVM, + BunString name, + BunString url, + BunString* errorMessage, + uint32_t parentContextId, + uint32_t contextId, + bool miniMode, + bool unrefByDefault, + bool evalMode, + StringImpl** argvPtr, + size_t argvLen, + bool defaultExecArgv, + StringImpl** execArgvPtr, + size_t execArgvLen, + BunString* preloadModulesPtr, + size_t preloadModulesLen); +// Raise a TerminationException in the worker VM at its next safepoint and wake its loop. Any thread. +void WebWorker__requestTermination(void*); +// Toggle the keep-alive this worker holds on the parent event loop. Parent thread. +void WebWorker__setRef(void*, bool); +// Release that keep-alive. Parent thread. +void WebWorker__releaseParentPollRef(void*); +// Block until the OS thread has exited. Parent thread, after the worker reported destroyed or was +// asked to terminate by an exiting parent. +void WebWorker__join(void*); +// Drop one ref on the thread object; the last one frees it. +void WebWorker__deref(void*); + +} // extern "C" + +WorkerMessagingProxy::WorkerMessagingProxy(Worker& workerObject, ScriptExecutionContext& parentContext, WorkerOptions&& options) + : m_scriptExecutionContext(&parentContext) + , m_workerObject(&workerObject) + , m_loaderContextIdentifier(parentContext.identifier()) + , m_workerContextIdentifier(ScriptExecutionContext::generateIdentifier()) + , m_options(WTF::move(options)) +{ + ASSERT(parentContext.isContextThread()); +} + +Ref WorkerMessagingProxy::create(Worker& workerObject, ScriptExecutionContext& parentContext, WorkerOptions&& options) +{ + return adoptRef(*new WorkerMessagingProxy(workerObject, parentContext, WTF::move(options))); +} + +WorkerMessagingProxy::~WorkerMessagingProxy() +{ + ASSERT(!m_workerObject); + ASSERT(!m_workerThread); + ASSERT(!m_scriptExecutionContext || m_scriptExecutionContext->isContextThread()); +} + +// ---- WorkerGlobalScopeProxy (parent thread) ------------------------------------------------------ + +ExceptionOr WorkerMessagingProxy::startWorkerGlobalScope(const String& scriptURL) +{ + ASSERT(m_scriptExecutionContext && m_scriptExecutionContext->isContextThread()); + ASSERT(!m_workerThread); + + // Constructed on a context whose active objects were already stopped: the Worker was stopped + // at birth (suspendIfNeeded -> stop -> terminate) and there is nothing to start. + if (m_askedToTerminate) { + m_state.store(State::Closed); + m_scriptExecutionContext = nullptr; + return {}; + } + + Vector preloadModules; + preloadModules.reserveInitialCapacity(m_options.preloadModules.size()); + for (auto& str : m_options.preloadModules) { + if (str.startsWith("file://"_s)) { + WTF::URL urlObject = WTF::URL(str); + if (!urlObject.isValid()) + return Exception { TypeError, makeString("Invalid file URL: \""_s, str, '"') }; + str = urlObject.fileSystemPath(); + } + preloadModules.append(Bun::toString(str)); + } + + static_assert(sizeof(WTF::String) == sizeof(WTF::StringImpl*)); + std::span execArgv = m_options.execArgv + .transform([](Vector& vec) -> std::span { + return { reinterpret_cast(vec.begin()), vec.size() }; + }) + .value_or(std::span {}); + + // The thread holds a ref on the proxy until releaseWorkerThread(). + ref(); + BunString errorMessage = BunStringEmpty; + m_workerThread = WebWorker__create( + this, + WebCore::clientData(m_scriptExecutionContext->vm())->bunVM, + Bun::toString(m_options.name), + Bun::toString(scriptURL), + &errorMessage, + m_loaderContextIdentifier, + m_workerContextIdentifier, + m_options.mini, + m_options.unref, + m_options.evalMode, + reinterpret_cast(m_options.argv.begin()), + m_options.argv.size(), + !m_options.execArgv.has_value(), + execArgv.data(), + execArgv.size(), + preloadModules.begin(), + preloadModules.size()); + m_options.preloadModules.clear(); + + if (!m_workerThread) { + m_state.store(State::Closed); + deref(); + return Exception { TypeError, errorMessage.toWTFString(BunString::ZeroCopy) }; + } + return {}; +} + +void WorkerMessagingProxy::terminateWorkerGlobalScope() +{ + if (m_askedToTerminate) + return; + m_askedToTerminate = true; + if (m_workerThread) + WebWorker__requestTermination(m_workerThread); +} + +void WorkerMessagingProxy::setKeepAlive(bool keepAlive) +{ + if (m_askedToTerminate || m_keepAliveReleased || !m_workerThread) + return; + WebWorker__setRef(m_workerThread, keepAlive); +} + +void WorkerMessagingProxy::workerObjectDestroyed() +{ + ASSERT(!m_scriptExecutionContext || m_scriptExecutionContext->isContextThread()); + m_workerObject = nullptr; + terminateWorkerGlobalScope(); +} + +void WorkerMessagingProxy::postMessageToWorkerGlobalScope(MessageWithMessagePorts&& message) +{ + { + Locker locker { m_toWorker.lock }; + // A terminated (or terminating) worker drains nothing more: drop, as a closed port does. + auto state = m_state.load(); + if (state == State::Closing || state == State::Closed) + return; + m_toWorker.queue.append(WTF::move(message)); + // Before Running the inbox is only buffered; workerGlobalScopeStarted() schedules the first + // drain on the worker thread. One drain task in flight at a time. + if (m_state.load() != State::Running || m_toWorker.drainScheduled) + return; + m_toWorker.drainScheduled = true; + } + bool posted = ScriptExecutionContext::postTaskTo(m_workerContextIdentifier, [protectedThis = Ref { *this }](ScriptExecutionContext& context) { + protectedThis->drainMessagesToWorkerGlobalScope(context); + }); + if (!posted) { + Locker locker { m_toWorker.lock }; + m_toWorker.drainScheduled = false; + } +} + +bool WorkerMessagingProxy::postTaskToWorkerGlobalScope(Function&& task) +{ + { + Locker lock { m_pendingTasksLock }; + switch (m_state.load()) { + case State::Pending: + m_pendingTasks.append(WTF::move(task)); + return true; + case State::Running: + break; + case State::Closing: + case State::Closed: + return false; + } + } + return ScriptExecutionContext::postTaskTo(m_workerContextIdentifier, WTF::move(task)); +} + +uint64_t WorkerMessagingProxy::registerCrossVMRequest(JSC::VM& vm, JSC::JSPromise* promise) +{ + uint64_t id = m_nextRequestId.fetch_add(1); + Locker lock { m_pendingTasksLock }; + m_pendingCrossVMRequests.add(id, JSC::Strong(vm, promise)); + return id; +} + +JSC::Strong WorkerMessagingProxy::takeCrossVMRequest(uint64_t id) +{ + Locker lock { m_pendingTasksLock }; + return m_pendingCrossVMRequests.take(id); +} + +void WorkerMessagingProxy::rejectAllCrossVMRequests() +{ + HashMap> pending; + { + Locker lock { m_pendingTasksLock }; + pending = std::exchange(m_pendingCrossVMRequests, {}); + } + if (pending.isEmpty() || !m_scriptExecutionContext) + return; + auto* globalObject = defaultGlobalObject(m_scriptExecutionContext->globalObject()); + auto& vm = JSC::getVM(globalObject); + for (auto& entry : pending) + entry.value->reject(vm, Bun::createError(globalObject, Bun::ErrorCode::ERR_WORKER_NOT_RUNNING, "Worker instance not running"_s)); +} + +// ---- Inbox drain (both directions) --------------------------------------------------------------- +// +// Mirrors MessagePortPipe::drainAndDispatch and Node's MessagePort::OnMessage: one task drains a +// bounded batch of messages, running microtasks after each so queueMicrotask/Promise callbacks +// observe them one at a time, then yields to the loop and reports whether more remain. The budget is +// a fixed count rather than "everything that was queued when the drain began": with a producer on +// another thread that snapshot can be arbitrarily large, and the receiving loop's timers and I/O +// wait behind it. `UntilEmpty` is for the sender having exited: the queue is finite and everything +// in it precedes 'close'. Worker inboxes never change owner, so up to a budget's worth is moved out +// under one lock acquisition and dispatched uncontended; the queue itself is only swapped out whole +// when it fits the budget, so a continuation never has to hand a tail back. +enum class DrainBudget { Bounded, + UntilEmpty }; +static constexpr size_t drainBatchLimit = 1024; + +template +static bool drainInbox(WorkerMessagingProxy::MessageInbox& inbox, Zig::GlobalObject& globalObject, ScriptExecutionContext& context, DrainBudget budget, Dispatch&& dispatch) +{ + size_t remaining = budget == DrainBudget::UntilEmpty ? std::numeric_limits::max() : drainBatchLimit; + + while (true) { + Deque batch; + { + Locker locker { inbox.lock }; + if (inbox.queue.isEmpty()) { + inbox.drainScheduled = false; + return false; + } + if (!remaining) + return true; // budget spent, messages left + if (inbox.queue.size() <= remaining) + batch = std::exchange(inbox.queue, {}); + else { + for (size_t i = 0; i < remaining; ++i) + batch.append(inbox.queue.takeFirst()); + } + } + if (budget == DrainBudget::Bounded) + remaining -= batch.size(); + + while (!batch.isEmpty()) { + // The receiving VM is being stopped: nothing more is delivered (the + // rest is dropped with the proxy). + if (context.isJSExecutionForbidden()) + return false; + auto message = batch.takeFirst(); + auto ports = MessagePort::entanglePorts(context, WTF::move(message.transferredPorts)); + auto event = MessageEvent::create(globalObject, message.message.releaseNonNull(), nullptr, WTF::move(ports)); + dispatch(event.event); + if (globalObject.drainMicrotasks()) + return false; // termination pending + } + } +} + +void WorkerMessagingProxy::drainMessagesToWorkerGlobalScope(ScriptExecutionContext& context) +{ + auto& globalObject = *defaultGlobalObject(context.globalObject()); + bool more = drainInbox(m_toWorker, globalObject, context, DrainBudget::Bounded, [&](Event& event) { + globalObject.globalEventScope->dispatchEvent(event); + }); + if (more) { + // Budget spent with messages left: continue after the loop has polled, + // or a producer faster than this drain starves timers and I/O for good. + context.postTaskAfterYield([protectedThis = Ref { *this }](ScriptExecutionContext& context) { + protectedThis->drainMessagesToWorkerGlobalScope(context); + }); + } +} + +void WorkerMessagingProxy::drainMessagesToWorkerObject(ScriptExecutionContext& context, DrainBudget budget) +{ + if (!m_workerObject) { + Locker locker { m_toParent.lock }; + m_toParent.queue.clear(); + m_toParent.drainScheduled = false; + return; + } + Ref workerObject = *m_workerObject; + auto& globalObject = *defaultGlobalObject(context.globalObject()); + bool more = drainInbox(m_toParent, globalObject, context, budget, [&](Event& event) { + workerObject->dispatchEvent(event); + }); + if (more) { + context.postTaskAfterYield([protectedThis = Ref { *this }](ScriptExecutionContext& context) { + protectedThis->drainMessagesToWorkerObject(context, DrainBudget::Bounded); + }); + } +} + +// ---- WorkerObjectProxy / WorkerReportingProxy (worker thread) ----------------------------------- + +void WorkerMessagingProxy::workerGlobalScopeStarted(Zig::GlobalObject& workerGlobalObject) +{ + auto& context = *workerGlobalObject.scriptExecutionContext(); + ASSERT(context.identifier() == m_workerContextIdentifier); + + // Pending -> Running under the lock postTaskToWorkerGlobalScope() takes, and before 'online' is + // posted: a parent-side 'online' handler may immediately post a task and must find Running. + Deque> pendingTasks; + { + Locker lock { m_pendingTasksLock }; + m_state.store(State::Running); + pendingTasks = std::exchange(m_pendingTasks, {}); + } + + ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, [protectedThis = Ref { *this }](ScriptExecutionContext&) { + RefPtr workerObject = protectedThis->m_workerObject; + if (!workerObject || !workerObject->hasEventListeners(eventNames().openEvent)) + return; + workerObject->dispatchEvent(Event::create(eventNames().openEvent, Event::CanBubble::No, Event::IsCancelable::No)); + }); + + // Tasks and messages that arrived while Pending. If the entry module installed a 'message' + // listener they run now; otherwise on the next tick, so a listener added right after startup + // (the common `parentPort.on('message')` in an async callback) still sees them. + auto deliver = [protectedThis = Ref { *this }, pendingTasks = WTF::move(pendingTasks)](ScriptExecutionContext& context) mutable { + for (auto& task : pendingTasks) + task(context); + { + Locker locker { protectedThis->m_toWorker.lock }; + if (protectedThis->m_toWorker.queue.isEmpty() || protectedThis->m_toWorker.drainScheduled) + return; + protectedThis->m_toWorker.drainScheduled = true; + } + protectedThis->drainMessagesToWorkerGlobalScope(context); + }; + if (workerGlobalObject.globalEventScope->hasActiveEventListeners(eventNames().messageEvent)) + deliver(context); + else + context.postTask(WTF::move(deliver)); +} + +void WorkerMessagingProxy::postMessageToWorkerObject(MessageWithMessagePorts&& message) +{ + { + Locker locker { m_toParent.lock }; + m_toParent.queue.append(WTF::move(message)); + if (m_toParent.drainScheduled) + return; + m_toParent.drainScheduled = true; + } + bool posted = ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, [protectedThis = Ref { *this }](ScriptExecutionContext& context) { + protectedThis->drainMessagesToWorkerObject(context, DrainBudget::Bounded); + }); + if (!posted) { + Locker locker { m_toParent.lock }; + m_toParent.drainScheduled = false; + } +} + +void WorkerMessagingProxy::postMessageErrorToWorkerObject(String&& message) +{ + ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, [protectedThis = Ref { *this }, message = WTF::move(message).isolatedCopy()](ScriptExecutionContext&) { + RefPtr workerObject = protectedThis->m_workerObject; + if (!workerObject) + return; + ErrorEvent::Init init; + init.message = message; + workerObject->dispatchEvent(ErrorEvent::create(eventNames().errorEvent, init, EventIsTrusted::Yes)); + }); +} + +bool WorkerMessagingProxy::postSerializedErrorToWorkerObject(Zig::GlobalObject& workerGlobalObject, JSC::JSValue value) +{ + // Top of the worker's error-dispatch stack: neither the structured clone (which can run script + // through getters even in NonThrowing mode) nor the `code` read may leave an exception behind. + auto& vm = JSC::getVM(&workerGlobalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + + auto serialized = SerializedScriptValue::create(workerGlobalObject, value, SerializationForStorage::No, SerializationErrorMode::NonThrowing); + CLEAR_IF_EXCEPTION(scope); + if (!serialized) + return false; + + // Structured clone keeps only the standard Error fields; Node's worker 'error' event also + // preserves a string `error.code` (lib/internal/error_serdes.js). + String errorCode; + if (value.isObject()) { + JSC::JSValue codeValue = value.getObject()->getIfPropertyExists(&workerGlobalObject, WebCore::builtinNames(vm).codePublicName()); + if (!scope.exception() && codeValue && codeValue.isString()) + errorCode = codeValue.toWTFString(&workerGlobalObject); + CLEAR_IF_EXCEPTION(scope); + } + + return ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, [protectedThis = Ref { *this }, serialized = serialized.releaseNonNull(), errorCode = WTF::move(errorCode).isolatedCopy()](ScriptExecutionContext& context) { + RefPtr workerObject = protectedThis->m_workerObject; + if (!workerObject) + return; + auto* globalObject = context.globalObject(); + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + JSC::JSValue deserialized = serialized->deserialize(*globalObject, globalObject, SerializationErrorMode::NonThrowing); + CLEAR_AND_RETURN_IF_EXCEPTION(scope, ); + if (!errorCode.isNull()) { + if (auto* errorObject = deserialized.getObject()) + errorObject->putDirect(vm, WebCore::builtinNames(vm).codePublicName(), JSC::jsString(vm, errorCode)); + } + ErrorEvent::Init init; + init.error = deserialized; + workerObject->dispatchEvent(ErrorEvent::create(eventNames().errorEvent, init, EventIsTrusted::Yes)); + }); +} + +void WorkerMessagingProxy::postErrorToWorkerObject(Zig::GlobalObject& workerGlobalObject, const String& message, JSC::JSValue error) +{ + switch (m_options.kind) { + case WorkerOptions::Kind::Web: + postMessageErrorToWorkerObject(String { message }); + return; + case WorkerOptions::Kind::Node: + if (!postSerializedErrorToWorkerObject(workerGlobalObject, error)) + postMessageErrorToWorkerObject(String { message }); + return; + } +} + +void WorkerMessagingProxy::workerGlobalScopeDestroyed(int32_t exitCode, bool stoppedByParent) +{ + // Last thing the worker thread does with this object. If the parent context is gone the task is + // dropped and the proxy (with the thread's ref on it) leaks; the parent's own exit path + // (parentContextWillDestroy) is what normally prevents that. + ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, [protectedThis = Ref { *this }, exitCode, stoppedByParent](ScriptExecutionContext&) { + protectedThis->workerGlobalScopeDestroyedInternal(exitCode, stoppedByParent); + }); +} + +// ---- Back on the parent thread ------------------------------------------------------------------- + +void WorkerMessagingProxy::releaseWorkerThread() +{ + ASSERT(!m_scriptExecutionContext || m_scriptExecutionContext->isContextThread()); + void* workerThread = std::exchange(m_workerThread, nullptr); + if (!workerThread) + return; + if (!std::exchange(m_keepAliveReleased, true)) + WebWorker__releaseParentPollRef(workerThread); + WebWorker__join(workerThread); + WebWorker__deref(workerThread); + m_state.store(State::Closed); + // The ref startWorkerGlobalScope() took on behalf of the thread. + deref(); +} + +void WorkerMessagingProxy::workerGlobalScopeDestroyedInternal(int32_t exitCode, bool stoppedByParent) +{ + ASSERT(m_scriptExecutionContext && m_scriptExecutionContext->isContextThread()); + Ref protectedThis { *this }; + + // node:worker_threads: a worker stopped by its parent once it was running reports 1 unless it + // called process.exit() itself (a process.exitCode it merely set is not used, as in Node). The + // Web Worker's 'close' event keeps 0 for that case (documented). + if (m_options.kind == WorkerOptions::Kind::Node && stoppedByParent) + exitCode = 1; + + // Closing while 'close' dispatches so handlers observe threadId == -1 / !isOnline() but a + // postMessage() from inside them is still accepted and dropped (browser/Node behaviour). + { + Locker lock { m_pendingTasksLock }; + m_state.store(State::Closing); + m_pendingTasks.clear(); + } + rejectAllCrossVMRequests(); + + // Everything the worker posted before it exited is delivered before 'close' (Node: before + // 'exit'); the thread is gone, so the queue is finite. A bounded drain still queued behind this + // task then finds it empty. + drainMessagesToWorkerObject(*m_scriptExecutionContext, DrainBudget::UntilEmpty); + + if (RefPtr workerObject = m_workerObject; workerObject && workerObject->hasEventListeners(eventNames().closeEvent)) { + auto event = CloseEvent::create(exitCode == 0, static_cast(exitCode), exitCode == 0 ? "Worker terminated normally"_s : "Worker exited abnormally"_s); + workerObject->dispatchCloseEvent(event); + } + + releaseWorkerThread(); + m_scriptExecutionContext = nullptr; +} + +void WorkerMessagingProxy::parentContextWillDestroy() +{ + ASSERT(m_scriptExecutionContext && m_scriptExecutionContext->isContextThread()); + // Usually already asked by the parent's stop phase (Worker::stop); a Worker whose object lives + // on another context of this thread (e.g. a ShadowRealm global) is only asked here. + terminateWorkerGlobalScope(); + if (!m_workerThread) { + m_scriptExecutionContext = nullptr; + return; + } + Ref protectedThis { *this }; + { + Locker lock { m_pendingTasksLock }; + m_state.store(State::Closing); + m_pendingTasks.clear(); + m_pendingCrossVMRequests.clear(); + } + releaseWorkerThread(); + m_scriptExecutionContext = nullptr; +} + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.h b/src/jsc/bindings/webcore/WorkerMessagingProxy.h new file mode 100644 index 000000000000..938b5d0cbfcf --- /dev/null +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.h @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2008-2017 Apple Inc. All rights reserved. + * Copyright (C) 2009 Google Inc. All Rights Reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "MessageWithMessagePorts.h" +#include "ScriptExecutionContext.h" +#include "WorkerOptions.h" +#include +#include +#include +#include +#include + +namespace JSC { +class JSGlobalObject; +class JSPromise; +class JSValue; +class VM; +} + +namespace Zig { +class GlobalObject; +} + +namespace WebCore { + +class Event; +class Worker; + +// The only object shared between a Worker (parent thread, script-visible) and the thread that runs +// its global scope. Created with the Worker; outlives both the Worker object and the thread. +// +// Refs: the Worker object's Ref member, the worker thread (taken before it is spawned, dropped on the +// parent thread by workerGlobalScopeDestroyedInternal() or by the parent's own exit path once it has +// joined the thread), and transient Refs captured by posted tasks. The last deref is therefore always +// on the parent thread. If the parent context is already gone when the thread finishes, nothing runs +// workerGlobalScopeDestroyedInternal() and the proxy is deliberately leaked (as upstream does). +// +// Everything a task posted from the worker thread wants to do to the Worker object goes through +// m_workerObject on the parent thread, which workerObjectDestroyed() nulls first. +enum class DrainBudget; + +class WorkerMessagingProxy final : public ThreadSafeRefCounted { + WTF_MAKE_TZONE_ALLOCATED(WorkerMessagingProxy); + +public: + enum class State : uint8_t { + Pending, // created; worker thread starting up + Running, // workerGlobalScopeStarted() has run on the worker thread + Closing, // workerGlobalScopeDestroyedInternal() is dispatching 'close' on the parent + Closed, // the thread is joined and released; nothing further will happen + }; + + static Ref create(Worker&, ScriptExecutionContext& parentContext, WorkerOptions&&); + ~WorkerMessagingProxy(); + + // -- WorkerGlobalScopeProxy (parent thread) -------------------------------------------------- + ExceptionOr startWorkerGlobalScope(const String& scriptURL); + void terminateWorkerGlobalScope(); + void postMessageToWorkerGlobalScope(MessageWithMessagePorts&&); + // Queued while Pending, posted while Running, refused (false) once Closing. + bool postTaskToWorkerGlobalScope(Function&&); + void setKeepAlive(bool); + void workerObjectDestroyed(); + // The parent context is exiting: the thread has been asked to stop; wait for it and release what + // workerGlobalScopeDestroyedInternal() would have released. Parent thread. + void parentContextWillDestroy(); + + bool askedToTerminate() const { return m_askedToTerminate; } + bool hasPendingActivity() const { return m_state.load() != State::Closed; } + bool isOnline() const { return m_state.load() == State::Running; } + bool isClosingOrClosed() const { return m_state.load() >= State::Closing; } + + uint64_t registerCrossVMRequest(JSC::VM&, JSC::JSPromise*); + JSC::Strong takeCrossVMRequest(uint64_t id); + + // -- WorkerObjectProxy / WorkerReportingProxy (worker thread) --------------------------------- + void workerGlobalScopeStarted(Zig::GlobalObject&); + void postMessageToWorkerObject(MessageWithMessagePorts&&); + void postErrorToWorkerObject(Zig::GlobalObject&, const String& message, JSC::JSValue error); + // The thread's global scope, VM and per-thread state are gone; only the OS thread remains. + // stoppedByParent: it stopped because it was asked to and never called process.exit() itself. + void workerGlobalScopeDestroyed(int32_t exitCode, bool stoppedByParent); + void drainMessagesToWorkerGlobalScope(ScriptExecutionContext&); + + // -- Either thread --------------------------------------------------------------------------- + WorkerOptions& options() { return m_options; } + ScriptExecutionContextIdentifier workerContextIdentifier() const { return m_workerContextIdentifier; } + ScriptExecutionContextIdentifier loaderContextIdentifier() const { return m_loaderContextIdentifier; } + void* workerThread() const { return m_workerThread; } + + struct MessageInbox { + Lock lock; + Deque queue WTF_GUARDED_BY_LOCK(lock); + bool drainScheduled WTF_GUARDED_BY_LOCK(lock) { false }; + }; + +private: + WorkerMessagingProxy(Worker&, ScriptExecutionContext& parentContext, WorkerOptions&&); + + void workerGlobalScopeDestroyedInternal(int32_t exitCode, bool stoppedByParent); + void releaseWorkerThread(); + void drainMessagesToWorkerObject(ScriptExecutionContext&, DrainBudget); + void rejectAllCrossVMRequests(); + void postMessageErrorToWorkerObject(String&& message); + bool postSerializedErrorToWorkerObject(Zig::GlobalObject&, JSC::JSValue error); + + // Parent thread only. + RefPtr m_scriptExecutionContext; + Worker* m_workerObject; + bool m_askedToTerminate { false }; + bool m_keepAliveReleased { false }; + + const ScriptExecutionContextIdentifier m_loaderContextIdentifier; + const ScriptExecutionContextIdentifier m_workerContextIdentifier; + WorkerOptions m_options; + + // The native thread object (src/jsc/web_worker.rs). Holds one ref on it from + // startWorkerGlobalScope() until releaseWorkerThread(). + void* m_workerThread { nullptr }; + + std::atomic m_state { State::Pending }; + + // Pending -> Running happens under this lock so a task posted while Pending is either queued here + // (and run by workerGlobalScopeStarted) or posted directly, never lost. + Lock m_pendingTasksLock; + Deque> m_pendingTasks WTF_GUARDED_BY_LOCK(m_pendingTasksLock); + HashMap> m_pendingCrossVMRequests WTF_GUARDED_BY_LOCK(m_pendingTasksLock); + std::atomic m_nextRequestId { 1 }; + + MessageInbox m_toWorker; + MessageInbox m_toParent; +}; + +} // namespace WebCore diff --git a/src/jsc/bindings/webcrypto/SubtleCrypto.h b/src/jsc/bindings/webcrypto/SubtleCrypto.h index cfbb625a92f9..450cf1a2bad1 100644 --- a/src/jsc/bindings/webcrypto/SubtleCrypto.h +++ b/src/jsc/bindings/webcrypto/SubtleCrypto.h @@ -58,8 +58,12 @@ class DeferredPromise; enum class CryptoAlgorithmIdentifier : uint8_t; -class SubtleCrypto : public ContextDestructionObserver, public RefCounted, public CanMakeWeakPtr { +class SubtleCrypto : public ContextDestructionObserver, public RefCounted { public: + // ContextDestructionObserver. + void ref() const final { RefCounted::ref(); } + void deref() const final { RefCounted::deref(); } + static Ref create(ScriptExecutionContext* context) { return adoptRef(*new SubtleCrypto(context)); } ~SubtleCrypto(); diff --git a/src/jsc/event_loop.rs b/src/jsc/event_loop.rs index 14ecee8fafa0..aa9643fe10ef 100644 --- a/src/jsc/event_loop.rs +++ b/src/jsc/event_loop.rs @@ -10,7 +10,7 @@ //! poll deadline). See PORTING.md §Dispatch. use core::ptr::NonNull; -use core::sync::atomic::{AtomicI32, AtomicPtr, AtomicU32, Ordering}; +use core::sync::atomic::{AtomicI32, AtomicPtr, Ordering}; use bun_io::{self as Async, Waker}; use bun_uws as uws; @@ -31,17 +31,13 @@ pub use bun_event_loop::DeferredTaskQueue::{self, DeferredRepeatingTask}; pub use bun_event_loop::ManagedTask; pub use bun_event_loop::MiniEventLoop; pub use bun_event_loop::Task; -pub use bun_event_loop::any_event_loop::{ - AnyEventLoop, EventLoopHandle, EventLoopTask, EventLoopTaskPtr, -}; +pub use bun_event_loop::any_event_loop::{AnyEventLoop, EventLoopHandle, EventLoopTask}; pub use bun_threading::work_pool::{Task as WorkPoolTask, WorkPool}; -pub use crate::concurrent_promise_task::ConcurrentPromiseTask; pub use crate::cpp_task::{ConcurrentCppTask, CppTask}; pub use crate::garbage_collection_controller::GarbageCollectionController; pub use crate::jsc_scheduler as JSCScheduler; pub use crate::posix_signal_handle::{PosixSignalHandle, PosixSignalTask}; -pub use crate::work_task::{WorkTask, WorkTaskContext}; bun_core::declare_scope!(EventLoop, hidden); @@ -50,6 +46,9 @@ pub type Queue = pub struct EventLoop { pub tasks: Queue, + /// Set when teardown releases the queue: from then on `enqueue_task` + /// releases instead of parking (nothing will tick this loop again). + closed_for_tasks: bool, /// setImmediate() gets it's own two task queues /// When you call `setImmediate` in JS, it queues to the start of the next tick @@ -67,8 +66,16 @@ pub struct EventLoop { /// (link-time `__bun_run_immediate_task`) casts it back. pub immediate_tasks: Vec<*mut ()>, pub next_immediate_tasks: Vec<*mut ()>, + /// Tasks that asked to run on the *next* loop iteration — after I/O and + /// timers have had a turn — rather than in the current drain, which runs + /// until the queue is empty (a task that re-posts itself there never lets + /// the loop poll). Promoted into `tasks` by `auto_tick`, like immediates. + pub yield_tasks: Vec, pub concurrent_tasks: ConcurrentQueue, + /// Set only on Bun.spawnSync's isolated loop: how other threads reach *this* + /// loop's queue (the VM's handle names only the VM's own two loops). + pub(crate) isolated_poster: Option>, // BACKREF — `*JSGlobalObject` owned by the VM; outlives this EventLoop. pub global: Option>, // BACKREF — owning `*VirtualMachine` (EventLoop is a value field of it). @@ -88,10 +95,6 @@ pub struct EventLoop { pub entered_event_loop_count: isize, pub concurrent_ref: AtomicI32, - /// Work-pool tasks that will post to `concurrent_tasks` when they finish (counted on the JS - /// thread before hand-off, decremented on the pool thread after the post). Shutdown waits for - /// zero before the final queue drain so a completion can't land after it and leak. - pub concurrent_posters: AtomicU32, /// Atomic nullable pointer to the next-due `WTFTimer`. /// /// Note (§Dispatch): payload is `*mut ()` — the real @@ -115,9 +118,12 @@ impl Default for EventLoop { fn default() -> Self { Self { tasks: Queue::init(), + closed_for_tasks: false, immediate_tasks: Vec::new(), next_immediate_tasks: Vec::new(), + yield_tasks: Vec::new(), concurrent_tasks: ConcurrentQueue::default(), + isolated_poster: None, global: None, virtual_machine: None, waker: None, @@ -132,7 +138,6 @@ impl Default for EventLoop { uws_loop: (), entered_event_loop_count: 0, concurrent_ref: AtomicI32::new(0), - concurrent_posters: AtomicU32::new(0), imminent_gc_timer: AtomicPtr::new(core::ptr::null_mut()), #[cfg(unix)] signal_handler: None, @@ -211,15 +216,10 @@ unsafe extern "Rust" { /// `WTFTimer::run` — `timer` is an erased `*mut bun_runtime::timer::WTFTimer`. /// Defined in `bun_runtime::dispatch`. Link-time resolved. fn __bun_run_wtf_timer(timer: *mut (), vm: *mut VirtualMachine); - /// Tag-specific shutdown release for a queued-but-never-run task. Called - /// from `release_queued_tasks_for_shutdown` (after `shutdown_for_exit`, - /// before `destructOnExit`) for every entry left in `self.tasks`. - /// Returns `true` iff the tag was consumed; `false` means the entry - /// must be left in the queue (it stays reachable from the static-rooted - /// VM box, which is the pre-`532a5411961b` behaviour for tags that don't - /// own JSC handles or whose callback isn't safe to no-op-dispatch). - /// Defined in `bun_runtime::dispatch`. Link-time resolved. - fn __bun_release_task_at_shutdown(task: bun_event_loop::Task) -> bool; + /// Free a queued task that will never run, through its type's + /// `Taskable::release_unrun` (one arm per tag in `bun_runtime::dispatch`). + /// JS thread, JSC heap alive. Link-time resolved. + fn __bun_release_task_unrun(task: bun_event_loop::Task); } #[inline] @@ -395,10 +395,12 @@ impl EventLoop { this_value: JSValue, arguments: &[JSValue], ) { - // A prior callback's microtasks can tear the worker down - // (worker.terminate()), leaving the termination exception pending; - // entering JS then trips executeCallImpl's `assertNoException`. Same - // gate as `tick_with_count()`; guarding here covers all 50+ callers. + // The gate for native code entering user JS from outside the task + // queue (all 50+ callers funnel through here): not once teardown has + // forbidden script (Node's `can_call_into_js`), and not with an + // exception already pending — a prior callback's microtasks can request + // termination (worker.terminate()), and entering JS then would trip + // executeCallImpl's `assertNoException`. if global_object.has_exception() { return; } @@ -434,6 +436,7 @@ impl EventLoop { this_value: JSValue, arguments: &[JSValue], ) -> JSValue { + // Same gate as `run_callback`. if global_object.has_exception() { return JSValue::ZERO; } @@ -475,8 +478,8 @@ impl EventLoop { let _ = self.tick_concurrent_with_count(); } - /// Check whether refConcurrently has been called but the change has not yet been applied to the - /// underlying event loop's `active` counter + /// Whether a keep-alive delta (`ref_keep_alive`, here or through a + /// `VmHandle`) has been queued but not yet applied to the loop's `active` count. pub fn has_pending_refs(&self) -> bool { self.concurrent_ref.load(Ordering::SeqCst) > 0 } @@ -495,7 +498,7 @@ impl EventLoop { } pub fn tick_concurrent_with_count(&mut self) -> usize { - self.update_counts(); + self.apply_concurrent_ref_delta(); #[cfg(unix)] { @@ -553,9 +556,15 @@ impl EventLoop { self.tasks.readable_length() - start_count } - fn update_counts(&mut self) { + /// Fold refs/unrefs queued through `ref_keep_alive`/`unref_keep_alive` + /// (here, or from another thread through `VmHandle`) into the platform + /// loop's keep-alive count. Runs at the top of every tick, + /// and once more from a worker's shutdown after its stop phase (which unrefs + /// ports/channels/sockets on a loop that no longer ticks) so the loop is not + /// torn down still believing something keeps it alive. + pub(crate) fn apply_concurrent_ref_delta(&self) { // Do NOT silently drop the swapped delta when the handle is - // missing — refs queued via `ref_concurrently()` would be lost forever. + // missing — queued refs would be lost forever. let delta = self.concurrent_ref.swap(0, Ordering::SeqCst); let loop_ = self .vm_ref() @@ -623,6 +632,20 @@ impl EventLoop { self.vm_ref().as_mut().gc_controller.process_gc_timer(); } + /// How many times one `tick()` refills the task queue from the concurrent + /// queue before returning to let the loop poll. Other threads can post + /// faster than this thread runs what they post; without a bound a steady + /// producer (a worker flooding postMessage) keeps `tick()` from ever + /// returning and timers / I/O never run. What is left is picked up by the + /// next `tick()`, after a non-blocking poll (`has_pending_tasks`). + const CONCURRENT_REFILLS_PER_TICK: u32 = 8; + + /// Work is queued that the next `tick()` will run: the poll before it must + /// not block. + pub fn has_pending_tasks(&self) -> bool { + self.tasks.readable_length() > 0 || !self.concurrent_tasks.is_empty() + } + pub fn tick(&mut self) { jsc::mark_binding(); crate::top_scope!(scope, self.global_ref()); @@ -639,8 +662,13 @@ impl EventLoop { let global = self.vm_ref().global(); let global_vm = self.vm_ref().jsc_vm(); - loop { + let mut refills = 0u32; + 'tick: loop { while self.tick_with_count(ctx) > 0 { + if refills == Self::CONCURRENT_REFILLS_PER_TICK { + break 'tick; + } + refills += 1; self.tick_concurrent(); self.global_ref().handle_rejected_promises(); } @@ -652,6 +680,10 @@ impl EventLoop { self.entered_event_loop_count -= 1; return; } + if refills == Self::CONCURRENT_REFILLS_PER_TICK { + break; + } + refills += 1; self.tick_concurrent(); if self.tasks.readable_length() > 0 { continue; @@ -659,7 +691,8 @@ impl EventLoop { break; } - while self.tick_with_count(ctx) > 0 { + while refills < Self::CONCURRENT_REFILLS_PER_TICK && self.tick_with_count(ctx) > 0 { + refills += 1; self.tick_concurrent(); } @@ -686,20 +719,24 @@ impl EventLoop { } pub fn enqueue_task(&mut self, task: Task) { + if self.closed_for_tasks { + // Teardown already released the queue and this loop never ticks + // again: release the task now, as `release_queued_tasks` would have + // — the queue owns refusal, like `VmHandle::post` does off-thread. + // SAFETY: JS thread, JSC heap alive (teardown phase B/C). + unsafe { __bun_release_task_unrun(task) }; + return; + } let _ = self.tasks.write_item(task); } - /// Drain `concurrent_tasks` without running them and `delete` any - /// `EventLoopTask*` payloads so their captured `Ref<>`s drop. Called from - /// `global_exit` after `terminate_all_workers_and_wait` (every worker has - /// posted its close task by then) and before `destructOnExit` (so - /// `~Worker` runs during the final GC sweep with the JSC VM still alive). - /// Without this, the last worker's close-task lambda — and the - /// `WebWorker` box reachable through its `protectedThis` — leak. - pub fn drop_concurrent_cpp_tasks(&mut self) { - unsafe extern "C" { - fn Bun__deleteEventLoopTask(task: *mut CppTask); - } + /// Move whatever other threads posted (`concurrent_tasks`) into + /// `self.tasks`, freeing the heap `ConcurrentTask` carriers, so one pass + /// over `self.tasks` releases everything. Called by `release_queued_tasks` + /// in teardown, after `join_child_workers()` (every child has posted its + /// close task by then) and before the JSC VM is destroyed (so captured + /// `Ref<>`s in queued C++ lambdas drop against a live heap). + fn take_concurrent_tasks(&mut self) { let mut iter = self.concurrent_tasks.pop_batch().iterator(); loop { let node = iter.next(); @@ -710,16 +747,7 @@ impl EventLoop { // iterator advanced past it before returning, so reading then // freeing here is sound. let (task, auto_delete) = unsafe { ((*node).task, (*node).auto_delete()) }; - if task.tag == bun_event_loop::task_tag::CppTask { - // SAFETY: every `CppTask` payload is a heap - // `WebCore::EventLoopTask*` (`ScriptExecutionContext::postTask*` - // → `new EventLoopTask`); we own it once popped. - unsafe { Bun__deleteEventLoopTask(task.ptr.cast::()) }; - } else { - // Hand non-Cpp payloads to `self.tasks` so `deinit()`'s - // existing per-tag reclaim handles them. - let _ = self.tasks.write_item(task); - } + let _ = self.tasks.write_item(task); if auto_delete { // SAFETY: heap-owned (see `ConcurrentTask::create`); not yet // freed, and the iterator no longer references it. @@ -728,80 +756,27 @@ impl EventLoop { } } - /// Release queued-but-never-run tasks that own a ref the dispatch path - /// would have dropped. Called from `global_exit` after `shutdown_for_exit` - /// (HTTP daemon parked, no further cross-thread posts) and before - /// `destructOnExit` (JSC still live, so `FetchTasklet::deinit` can drop - /// its `Strong`/`Weak` handles). Re-runs `drop_concurrent_cpp_tasks` first - /// so any task the HTTP thread posted after the earlier drain — its - /// `is_shutting_down()` read is non-atomic and can lag — is forwarded into - /// `self.tasks` for the per-tag release below. - /// - /// `ManagedTask` entries are deliberately re-queued rather than freed: - /// owners (e.g. `SendQueue.close_next_tick` / `after_close_task`) keep raw - /// back-pointers that they `cancel()` from `Drop`, and those `Drop`s fire - /// during `destructOnExit` (`Subprocess::finalize` → `SendQueue::drop`). - /// Freeing the box here would leave those pointers dangling and make - /// `cancel()` a heap-use-after-free. `deinit()` runs after `destructOnExit` - /// — every owner has cancelled and cleared its pointer by then — so it is - /// the correct teardown point for `ManagedTask`s. - /// - /// Tags `__bun_release_task_at_shutdown` doesn't claim are likewise - /// re-queued so they remain reachable from the static-rooted VM box (the - /// pre-`532a5411961b` state). Consuming them without freeing unhooked that - /// root and surfaced the boxes as direct leaks; the definer can't safely - /// dispatch every erased callback at shutdown. - pub fn release_queued_tasks_for_shutdown(&mut self) { - self.drop_concurrent_cpp_tasks(); - let mut requeue: Vec = Vec::new(); + /// Release, without running, every task still queued — what other + /// threads posted and what this thread enqueued — through each type's + /// `Taskable::release_unrun`, and refuse (release on arrival) anything + /// enqueued from here on. Teardown phase B: JS thread, script forbidden, + /// JSC heap alive, HTTP thread parked / children joined. + pub fn release_queued_tasks(&mut self) { + self.closed_for_tasks = true; + self.take_concurrent_tasks(); + let _ = self.promote_yield_tasks(); while let Some(task) = self.tasks.read_item() { - // SAFETY: tag-specific release (drops JSC handles while the VM is - // still live); definer in `bun_runtime::dispatch` matches the same - // tag set `tick_queue_with_count` does. `false` ⇒ not handled. - let consumed = task.tag != bun_event_loop::task_tag::ManagedTask - && unsafe { __bun_release_task_at_shutdown(task) }; - if !consumed { - requeue.push(task); - } - } - for task in requeue { - let _ = self.tasks.write_item(task); + // SAFETY: JS thread, heap alive; `task` just left the queue. + unsafe { __bun_release_task_unrun(task) }; } + // Pending immediates likewise: cancelling one drops its keep-alive on + // this thread's loop, so it happens now, not after the loop is gone. + self.release_pending_immediates(); } - pub fn deinit(&mut self) { - // Free (don't run — running could re-enter the dying VM) queued - // ManagedTask boxes. Other tags are left in place: they were re-queued - // by `release_queued_tasks_for_shutdown` because their callback can't - // be no-op-dispatched safely (some callbacks call into JS) and - // their box may be aliased by the originator. Keeping them in - // `self.tasks` (a field of the static-rooted `VirtualMachine` box that - // is never `dealloc`'d) leaves the chain reachable to LSan — the same - // visibility they had via `concurrent_tasks` before - // `drop_concurrent_cpp_tasks` drained it. CppTasks must NOT be deleted - // here: this runs after JSC VM teardown on both worker and main paths, - // and a Worker dispatchExit task's `~Ref` would walk freed - // WeakBlock storage via `~JSEventListener`. They are reclaimed before - // teardown by `release_queued_tasks_for_shutdown`'s CppTask arm. - let mut requeue: Vec = Vec::new(); - while let Some(task) = self.tasks.read_item() { - if task.tag == bun_event_loop::task_tag::ManagedTask { - // SAFETY: every ManagedTask is heap_owned (ManagedTask::new -> heap::into_raw). - let managed = - unsafe { bun_core::heap::take(task.ptr.cast::()) }; - if let (Some(cleanup), Some(ctx)) = (managed.cleanup, managed.ctx) { - cleanup(ctx.as_ptr()); - } - drop(managed); - } else { - requeue.push(task); - } - } - // Reassigning a fresh value drops the old buffers in place. - self.tasks = Queue::init(); - for task in requeue { - let _ = self.tasks.write_item(task); - } + /// Cancel (never run) every queued ImmediateObject; each cancel drops the + /// immediate's keep-alive on this loop, so the loop must still exist. + fn release_pending_immediates(&mut self) { let pending = core::mem::take(&mut self.immediate_tasks); let next = core::mem::take(&mut self.next_immediate_tasks); if !pending.is_empty() || !next.is_empty() { @@ -811,10 +786,26 @@ impl EventLoop { unsafe { __bun_cancel_pending_immediate(task, vm) }; } } - // Free the deferred-task map's storage. The tasks must not be run (same rule as the - // queued tasks above), and an entry owns nothing but a `Copy` ctx pointer whose owner - // released it when the JSC teardown before this finalized it. A worker's VM box is - // `dealloc`'d without running `Drop` (WebWorker::shutdown), so nothing else frees it. + } + + pub fn deinit(&mut self) { + // Everything queued was released by `release_queued_tasks` (which + // also made later enqueues release on arrival) and refused posts never + // reach `concurrent_tasks`; nothing can be left to leak with the VM box. + debug_assert!( + self.tasks.readable_length() == 0 && self.concurrent_tasks.is_empty(), + "queued tasks must be released (release_queued_tasks) before the loop is destroyed" + ); + debug_assert!( + self.immediate_tasks.is_empty() && self.next_immediate_tasks.is_empty(), + "pending immediates must be released (release_queued_tasks) while the loop is alive" + ); + self.tasks = Queue::init(); + // Free the deferred-task map's storage. The tasks must not be run, and an + // entry owns nothing but a `Copy` ctx pointer whose owner released it when + // the JSC teardown before this finalized it. A worker's VM box is + // `dealloc`'d without running `Drop` (WebWorker::shutdown), so nothing + // else frees it. self.deferred_tasks = DeferredTaskQueue::DeferredTaskQueue::default(); } @@ -824,6 +815,26 @@ impl EventLoop { self.immediate_tasks.push(task); } + /// See [`EventLoop::yield_tasks`]. + pub fn enqueue_task_after_yield(&mut self, task: Task) { + if self.closed_for_tasks { + return self.enqueue_task(task); + } + self.yield_tasks.push(task); + } + + /// `auto_tick`, before it polls: last iteration's yielded tasks become + /// runnable. Returns whether there are any, so the poll does not block. + pub fn promote_yield_tasks(&mut self) -> bool { + if self.yield_tasks.is_empty() { + return false; + } + for task in core::mem::take(&mut self.yield_tasks) { + let _ = self.tasks.write_item(task); + } + true + } + /// `tickImmediateTasks` — swaps the two /// immediate queues, drains the now-current batch, then recycles the /// drained Vec as the next-tick buffer. @@ -958,22 +969,26 @@ impl EventLoop { self.vm_ref().as_mut().auto_tick_active(); } - /// `eventLoop().waitForPromise(promise)` — spin tick/auto_tick until - /// `promise` settles or execution is forbidden. - pub fn wait_for_promise(&mut self, promise: jsc::AnyPromise) { + /// Ticks until `promise` settles. `Err` when it returns with the promise + /// still pending because the VM can no longer run the script that would + /// settle it (execution forbidden, or a stop was requested: a worker being + /// terminated mid-wait) — a `JsError::Terminated` for the caller. + pub fn wait_for_promise(&mut self, promise: jsc::AnyPromise) -> Result<(), jsc::JsTerminated> { let jsc_vm = self.vm_ref().jsc_vm(); if promise.status() != PromiseStatus::Pending { - return; + return Ok(()); } while promise.status() == PromiseStatus::Pending { - if jsc_vm.execution_forbidden() { - break; + if jsc_vm.execution_forbidden() || !self.vm_ref().script_allowed() { + jsc_vm.ensure_termination_exception_pending(); + return Err(jsc::JsTerminated::JSTerminated); } self.tick(); if promise.status() == PromiseStatus::Pending { self.auto_tick(); } } + Ok(()) } pub fn wakeup(&self) { @@ -997,51 +1012,29 @@ impl EventLoop { } } - /// `task` must be a live `ConcurrentTaskItem` that the queue may take - /// ownership of via its intrusive `next` link. All callers pass a - /// freshly-allocated or struct-embedded task — never null. - pub fn enqueue_task_concurrent(&self, task: core::ptr::NonNull) { - if cfg!(debug_assertions) { - if self.vm_ref().has_terminated { - panic!("EventLoop.enqueueTaskConcurrent: VM has terminated"); - } + /// JS thread: the poster other threads use to reach the loop this + /// `EventLoop` is — the VM's handle for its embedded loops, or the isolated + /// loop's own poster for a spawnSync loop. + pub fn js_poster(&self) -> bun_event_loop::JsPoster { + match &self.isolated_poster { + Some(p) => crate::vm_handle::IsolatedPosterInner::to_js_poster(p), + None => self.vm_ref().js_poster(), } - self.concurrent_tasks.push(task); - self.wakeup(); - } - - /// See `concurrent_posters`. Call on the JS thread before scheduling a - /// work-pool task whose completion posts via `enqueue_task_concurrent`. - pub fn concurrent_poster_begin(&self) { - let _ = self.concurrent_posters.fetch_add(1, Ordering::SeqCst); - } - - /// Pool-thread pair of [`Self::concurrent_poster_begin`]. Must be the - /// poster's last touch of this event loop: once the count hits zero the - /// shutdown thread may drain the queue and tear the VM down. - pub fn concurrent_poster_end(&self) { - let prev = self.concurrent_posters.fetch_sub(1, Ordering::Release); - debug_assert!(prev > 0); } - /// Spin until every counted poster has finished its post. Called on the JS thread during - /// shutdown, after the last JS has run and before the final queue drain. Each pending fs - /// operation is finite, so the wait is bounded by syscall latency. - pub fn wait_for_concurrent_posters(&self) { - while self.concurrent_posters.load(Ordering::Acquire) > 0 { - std::thread::yield_now(); - } - } - - pub fn ref_concurrently(&self) { + /// JS thread: count one more thing keeping this loop alive (the same + /// counter a `VmHandle::ref_keep_alive` from another thread adjusts). + pub fn ref_keep_alive(&self) { let _ = self.concurrent_ref.fetch_add(1, Ordering::SeqCst); - self.wakeup(); + // Fold now: JS between the last tick and the poll (an immediate, a + // promise reaction) must not leave the loop's active count stale. + self.apply_concurrent_ref_delta(); } - pub fn unref_concurrently(&self) { - // TODO maybe this should be AcquireRelease + /// JS thread: balance a [`Self::ref_keep_alive`]. + pub fn unref_keep_alive(&self) { let _ = self.concurrent_ref.fetch_sub(1, Ordering::SeqCst); - self.wakeup(); + self.apply_concurrent_ref_delta(); } // ──────────── private helpers ──────────── @@ -1107,6 +1100,10 @@ impl EventLoop { this_value: JSValue, arguments: &[JSValue], ) -> JsResult { + // Same gate as `run_callback`. + if global_object.has_exception() { + return Ok(JSValue::UNDEFINED); + } let result = callback.call(global_object, this_value, arguments)?; result.ensure_still_alive(); let jsc_vm = global_object.bun_vm().jsc_vm(); @@ -1181,34 +1178,36 @@ impl EventLoop { self.tick(); } - pub fn wait_for_promise_with_termination(&mut self, promise: jsc::AnyPromise) { - // BACKREF — `WebWorker` is owned by C++ and outlives this VM (see - // [`VirtualMachine::worker_ref`]); route through the safe accessor - // instead of open-coding the raw `*const c_void` cast + deref. - let worker = self - .vm_ref() - .worker_ref() - .expect("worker is not initialized"); - match promise.status() { - PromiseStatus::Pending => { - while !worker.has_requested_terminate() - && promise.status() == PromiseStatus::Pending - { - self.tick(); - if !worker.has_requested_terminate() - && promise.status() == PromiseStatus::Pending - { - // Unsettled top-level await: the loop has drained but the - // entry module's evaluation promise is still pending. Stop - // waiting so the worker can exit (node uses exit code 13). - if !self.vm_ref().is_event_loop_alive() { - break; - } - self.auto_tick(); - } - } + /// Drive the loop while a worker's entry module graph is fetched and + /// linked, until its evaluation has begun (`entry_evaluation_started`, set + /// by the moduleLoaderEvaluate hook once the linked graph starts executing), + /// the promise settled, or termination was requested. Parks in `auto_tick` + /// while imports are still being read/transpiled off-thread; does not wait + /// for a top-level await. + pub fn wait_for_worker_entry_evaluation(&mut self, promise: jsc::AnyPromise) { + loop { + let vm = self.vm_ref(); + let terminated = vm.worker_ref().is_some_and(|w| w.has_requested_terminate()); + if terminated + || vm.entry_evaluation_started + || promise.status() != PromiseStatus::Pending + { + break; + } + self.tick(); + let vm = self.vm_ref(); + let terminated = vm.worker_ref().is_some_and(|w| w.has_requested_terminate()); + if terminated + || vm.entry_evaluation_started + || promise.status() != PromiseStatus::Pending + { + break; + } + if !vm.is_event_loop_alive() { + // Nothing in flight can settle the load; let spin() decide. + break; } - _ => {} + self.auto_tick(); } } } @@ -1342,8 +1341,7 @@ bun_event_loop::link_impl_JsEventLoop! { enter() => (*this).enter(), exit() => (*this).exit(), enqueue_task(task) => (*this).enqueue_task(task), - enqueue_task_concurrent(task) => (*this).enqueue_task_concurrent(task), - concurrent_poster_end() => (*this).concurrent_poster_end(), + js_poster() => (*this).js_poster(), env() => (*this).vm_ref().transpiler.env, top_level_dir() => core::ptr::from_ref::<[u8]>((*this).vm_ref().top_level_dir()), create_null_delimited_env_map() => @@ -1393,13 +1391,22 @@ pub(crate) fn __bun_spawn_sync_create_event_loop(vm: *mut (), uws_loop: *mut uws { let _ = uws_loop; } - bun_core::heap::into_raw(el).cast() + let el = bun_core::heap::into_raw(el); + // SAFETY: `el` is the stable heap address the poster targets until destroy. + unsafe { (*el).isolated_poster = Some(crate::vm_handle::IsolatedPosterInner::new(el)) }; + el.cast() } #[unsafe(no_mangle)] pub(crate) fn __bun_spawn_sync_destroy_event_loop(el: *mut ()) { + let el = el.cast::(); + // Refuse (and wait out) posts from other threads before the loop goes. + // SAFETY: `el` is the live isolated loop; JS thread. + if let Some(p) = unsafe { (*el).isolated_poster.as_ref() } { + p.close(); + } // SAFETY: paired with `heap::alloc` in `__bun_spawn_sync_create_event_loop`. - drop(unsafe { bun_core::heap::take(el.cast::()) }); + drop(unsafe { bun_core::heap::take(el) }); } /// Re-bind `event_loop.{global, virtual_machine}` to `vm` (prepare path). diff --git a/src/jsc/hot_reloader.rs b/src/jsc/hot_reloader.rs index 41c1d0c74052..a95eba4b929e 100644 --- a/src/jsc/hot_reloader.rs +++ b/src/jsc/hot_reloader.rs @@ -106,12 +106,8 @@ pub type WatchReloader = NewHotReloader; impl HotReloaderCtx for VirtualMachine { type EventLoop = EventLoop; - fn event_loop(&self) -> *mut EventLoop { - VirtualMachine::event_loop(self) - } - - fn event_loop_ref(&self) -> &EventLoop { - VirtualMachine::event_loop_shared(self) + fn reload_handle(&self) -> Option { + Some(self.handle()) } fn bun_watcher_mut(&mut self) -> &mut Watcher { @@ -216,14 +212,10 @@ pub type WatchReloadTask = Task; pub trait HotReloaderCtx { type EventLoop; - fn event_loop(&self) -> *mut Self::EventLoop; - - /// Safe `&EventLoop` accessor. The event loop is owned by the `Ctx` (a - /// sibling field on `VirtualMachine`, or unreachable for the - /// `RELOAD_IMMEDIATELY` BundleV2 instantiation) and outlives the reloader, - /// so callers go through this instead of dereferencing the raw - /// `event_loop()` pointer at each site. - fn event_loop_ref(&self) -> &Self::EventLoop; + /// The handle the watcher thread posts reload tasks through (captured + /// once at reloader init, on the owning thread). `None` for contexts that + /// reload the whole process before ever enqueueing (`bun build --watch`). + fn reload_handle(&self) -> Option; /// Implementor returns the live `Watcher` regardless of how it's stored. fn bun_watcher_mut(&mut self) -> &mut Watcher; @@ -262,32 +254,6 @@ pub trait HotReloaderCtx { fn compute_clear_screen(&self) -> bool; } -/// Trait bound on the `EventLoopType` generic. The only concrete event -/// loop ever instantiated is `crate::event_loop::EventLoop`. -pub trait HotReloaderEventLoop { - /// Forward to the inherent `enqueue_task_concurrent`. Takes `&Self` so - /// the raw-pointer dereference of the `Ctx`-owned `*mut EventLoopType` is - /// narrowed to the two call sites, not spread across the trait + impls. - fn enqueue_task_concurrent(this: &Self, task: core::ptr::NonNull); -} - -impl HotReloaderEventLoop for EventLoop { - fn enqueue_task_concurrent(this: &Self, task: core::ptr::NonNull) { - // Inherent `EventLoop::enqueue_task_concurrent(&self, ..)` — inherent - // methods take precedence over trait methods, so this is not recursive. - this.enqueue_task_concurrent(task); - } -} - -/// `bun build --watch` instantiates `NewHotReloader`. With -/// `RELOAD_IMMEDIATELY = true`, `Task::enqueue` either diverges or takes the kill-signal branch — -/// the latter needs `watch_kill_signal_has_listeners()`, never true here, so this is never reached. -impl HotReloaderEventLoop for bun_event_loop::AnyEventLoop { - fn enqueue_task_concurrent(_this: &Self, _task: core::ptr::NonNull) { - unreachable!() - } -} - /// Type-erased view of a `Task` so /// `HotReloaderCtx::reload` doesn't need to name the const generics. pub trait HotReloadTaskView { @@ -442,6 +408,9 @@ pub struct NewHotReloader { #[cfg(not(windows))] pub(crate) tombstones: StringHashMap<*mut Fs::EntriesOption>, + /// See [`HotReloaderCtx::reload_handle`]. + pub(crate) reload_handle: Option, + _event_loop: PhantomData<*mut EventLoopType>, } @@ -521,7 +490,6 @@ impl Task where Ctx: HotReloaderCtx, - EventLoopType: HotReloaderEventLoop, { pub(crate) fn init_empty( reloader: *mut NewHotReloader, @@ -591,7 +559,30 @@ where // SAFETY: precondition — `this` came from heap::alloc in `enqueue`. drop(unsafe { bun_core::heap::take(this) }); } +} +impl bun_event_loop::Taskable + for Task +where + Ctx: HotReloaderCtx, +{ + const TAG: bun_event_loop::TaskTag = if RELOAD_IMMEDIATELY { + task_tag::WatchReloadTask + } else { + task_tag::HotReloadTask + }; + /// A file change the watcher thread posted that will not reload anything. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — the box `enqueue` posted. + unsafe { Self::deinit(this) } + } +} + +impl + Task +where + Ctx: HotReloaderCtx, +{ pub fn run(&mut self) { // Since we rely on the event loop for hot reloads, there can be // a delay before the next reload begins. In the time between the @@ -641,29 +632,25 @@ where })); // SAFETY: `that` was just allocated above and is exclusively owned here. unsafe { - // Note: `JscTask::init` requires `Taskable`, but const-generic - // `Task` can't implement it (one tag per monomorphization). - // Use the raw `(tag, ptr)` constructor. - let tag = if RELOAD_IMMEDIATELY { - task_tag::WatchReloadTask - } else { - task_tag::HotReloadTask - }; let concurrent = (*that).concurrent_task.insert(ConcurrentTask { - task: JscTask::new(tag, that.cast::<()>()), + task: JscTask::init(that), ..Default::default() }); - // `&that.concurrent_task` is interior to a Box-allocated Task; the loop must not - // outlive `that`. Inlines `enqueue_task_concurrent` to avoid forming a whole-struct - // `&NewHotReloader`. BundleV2/AnyEventLoop reach here only with RELOAD_IMMEDIATELY=false. - let ctx = self.ctx_ptr(); - // SAFETY: ctx outlives reloader (BACKREF); `event_loop()` returns - // the live event-loop pointer owned by `Ctx`. - let event_loop = &*(*ctx).event_loop(); - EventLoopType::enqueue_task_concurrent( - event_loop, + // `&that.concurrent_task` is an interior pointer into the + // Box-allocated Task. `RELOAD_IMMEDIATELY` already diverged above, so + // a handle is always present here. + // Field-only access to avoid forming a whole-struct `&NewHotReloader` + // (see `Self::pending_count` doc). + let handle = (*core::ptr::addr_of!((*self.reloader).reload_handle)) + .as_ref() + .expect("reload_handle set for a reloader that enqueues"); + if let crate::vm_handle::Posted::Refused(_) = handle.post( + crate::LoopKind::Regular, core::ptr::NonNull::from(concurrent), - ); + ) { + // VM torn down while a change was pending: drop the reload task. + Self::deinit(that); + } } self.count = 0; @@ -740,7 +727,6 @@ impl NewHotReloader where Ctx: HotReloaderCtx, - EventLoopType: HotReloaderEventLoop, { fn debug(args: core::fmt::Arguments<'_>) { bun_core::pretty_errorln!("watcher: {}", args); @@ -766,6 +752,8 @@ where main: MainFile::init(entry_path.unwrap_or(b"")), #[cfg(not(windows))] tombstones: StringHashMap::default(), + // SAFETY: see above. + reload_handle: unsafe { (*this).reload_handle() }, _event_loop: PhantomData, })); @@ -1308,7 +1296,6 @@ impl bun_watcher::WatcherCon for NewHotReloader where Ctx: HotReloaderCtx, - EventLoopType: HotReloaderEventLoop, { fn on_file_update( &mut self, @@ -1333,17 +1320,10 @@ where impl<'a> HotReloaderCtx for bun_bundler::BundleV2<'a> { type EventLoop = bun_event_loop::AnyEventLoop; - fn event_loop(&self) -> *mut Self::EventLoop { - // With RELOAD_IMMEDIATELY=true the only caller (`Task::enqueue`) - // diverges or takes the kill-signal branch first, and BundleV2 never - // has kill-signal listeners, so this is dead code. - unreachable!() - } - - fn event_loop_ref(&self) -> &Self::EventLoop { - // See `event_loop` above — dead for BundleV2 under - // RELOAD_IMMEDIATELY=true (no kill-signal listeners). - unreachable!() + fn reload_handle(&self) -> Option { + // RELOAD_IMMEDIATELY=true, and BundleV2 never has watch-kill-signal + // listeners: `Task::enqueue` re-execs the process before it would post. + None } fn bun_watcher_mut(&mut self) -> &mut Watcher { diff --git a/src/jsc/job.rs b/src/jsc/job.rs new file mode 100644 index 000000000000..d71a8b480ee0 --- /dev/null +++ b/src/jsc/job.rs @@ -0,0 +1,550 @@ +//! Work that leaves the JS thread and comes back. +//! +//! A [`Job`] is created on the JS thread, does its heavy part on the +//! [`WorkPool`], and completes on the JS thread again — unless its VM went away +//! meanwhile. Which thread may touch which part of it is in the types: +//! +//! * [`JobContext::OffThread`] is what the pool body sees. It is `Send`, and it +//! runs under a VM [`Borrow`] the carrier takes for it, so the VM's teardown +//! waits for a body that is mid-flight and a body never starts against a VM +//! that is already closed. JS-backed memory it needs is reachable only through +//! [`JsPtr`], i.e. only while that borrow (or a [`JsThread`]) is in hand. +//! * [`JobContext::Js`] is the completion's JS-thread state (promise, callback, +//! wrapper refs, pins, protected buffers). It is [`JsAffine`] and lives in a +//! [`JsSide`], which opens only with a [`JsThread`] token and is never dropped +//! implicitly. Every VM keeps the list of its live jobs ([`JobList`]) and +//! releases their JS sides itself at teardown, on its own thread with the +//! heap alive; a job the pool finishes after that frees only its off-thread +//! part. So there is no per-job "the VM is gone" code, and a JS side is +//! never touched off its thread. +//! +//! Node's equivalent is `ThreadPoolWork` + `req_wrap` (with the environment +//! cancelling/settling its reqs at cleanup); WebCore's is +//! `WorkerRunLoop::postTask` with `ActiveDOMObject`-owned completions. + +use core::marker::PhantomData; +use core::mem::ManuallyDrop; +use core::ptr::NonNull; + +use bun_io::KeepAlive; +use bun_threading::work_pool::{Task as WorkPoolTask, WorkPool}; + +use crate::debugger::AsyncTaskTracker; +use crate::virtual_machine::VirtualMachine; +use crate::vm_handle::{Borrow, LoopHandle}; +use crate::{JSGlobalObject, JsResult}; + +// ── tokens ──────────────────────────────────────────────────────────────── + +/// Proof that the holder is on `global`'s JS thread with its heap alive: what +/// it takes to open a [`JsSide`] or dereference a [`JsPtr`] outside a pool +/// borrow. Host functions and event-loop dispatch have one by construction +/// ([`JSGlobalObject::js_thread`]). Not `Send`. +pub struct JsThread<'a> { + global: &'a JSGlobalObject, + _not_send: PhantomData<*mut ()>, +} + +impl<'a> JsThread<'a> { + #[inline] + pub fn global(&self) -> &'a JSGlobalObject { + self.global + } + #[inline] + pub fn vm(&self) -> &'a VirtualMachine { + self.global.bun_vm() + } +} + +impl JSGlobalObject { + /// A live `&JSGlobalObject` is only ever formed on its own thread (it is an + /// opaque engine handle); debug builds check. + #[inline] + pub fn js_thread(&self) -> JsThread<'_> { + #[cfg(debug_assertions)] + self.bun_vm().handle().assert_js_thread(); + JsThread { + global: self, + _not_send: PhantomData, + } + } +} + +// ── JsAffine / JsSide ───────────────────────────────────────────────────── + +/// A value that may only be used and dropped on its VM's JS thread: GC +/// handles, keep-alives, wrapper back-pointers, pins, GC protection, and +/// anything built from them. In a job it lives on the [`Js`](JobContext::Js) +/// side, which the carrier guarantees is opened and dropped only there. +/// Derive it (`#[derive(bun_jsc::JsAffine)]`) for aggregates; the derive +/// checks every field. +/// +/// # Safety +/// Implement only for types whose every use and whose `Drop` are sound on the +/// owning JS thread with the heap alive (and need not be sound elsewhere). +pub unsafe trait JsAffine {} + +// SAFETY (each below): a GC/loop handle or plain data — used and dropped only +// on the owning JS thread by construction of `JsSide`. +// SAFETY: see the group note above. +unsafe impl JsAffine for crate::Strong {} +// SAFETY: see the group note above. +unsafe impl JsAffine for crate::StrongOptional {} +// SAFETY: see the group note above. +unsafe impl JsAffine for crate::JSPromiseStrong {} +// SAFETY: see the group note above. +unsafe impl JsAffine for crate::Weak {} +// SAFETY: see the group note above. +unsafe impl JsAffine for crate::JsRef {} +// SAFETY: see the group note above. +unsafe impl JsAffine for crate::JSValue {} +// SAFETY: see the group note above. +unsafe impl JsAffine for crate::GlobalRef {} +// SAFETY: see the group note above. +unsafe impl JsAffine for bun_ptr::BackRef {} +// SAFETY: see the group note above. +unsafe impl JsAffine for KeepAlive {} +// SAFETY: see the group note above. +unsafe impl JsAffine for AsyncTaskTracker {} +// SAFETY: see the group note above. +unsafe impl JsAffine for () {} +// SAFETY: see the group note above. +unsafe impl JsAffine for bool {} +// SAFETY: see the group note above. +unsafe impl JsAffine for Option {} +// SAFETY: see the group note above. +unsafe impl JsAffine for Box {} +// SAFETY: see the group note above. +unsafe impl JsAffine for (A, B) {} +// SAFETY: see the group note above. +unsafe impl JsAffine for (A, B, C) {} +// SAFETY: see the group note above. +unsafe impl JsAffine for JsPtr {} +// SAFETY: see the group note above. +unsafe impl JsAffine for Protected {} + +/// A GC-protected value a job's completion needs (Node: a `Global` on +/// the req_wrap). Unprotected on drop. +pub struct Protected(crate::JSValue); +impl Protected { + pub fn new(value: crate::JSValue) -> Self { + value.protect(); + Self(value) + } + #[inline] + pub fn value(&self) -> crate::JSValue { + self.0 + } +} +impl Drop for Protected { + fn drop(&mut self) { + self.0.unprotect(); + } +} + +/// The JS-thread partition of a job. Opens only with a [`JsThread`] and has +/// no implicit `Drop`: its contents are released by [`take`](Self::take) on the +/// JS thread (completion, or the VM's teardown), which is what makes it sound +/// to carry across threads inside a job. +#[repr(transparent)] +pub struct JsSide(ManuallyDrop); + +// SAFETY: the contents are unreachable without a `JsThread`, which exists only +// on the owning JS thread; off that thread a `JsSide` is inert bytes. +unsafe impl Send for JsSide {} + +impl JsSide { + #[inline] + pub fn new(js: J, _: &JsThread<'_>) -> Self { + Self(ManuallyDrop::new(js)) + } + #[inline] + pub fn get(&self, _: &JsThread<'_>) -> &J { + &self.0 + } + #[inline] + pub fn get_mut(&mut self, _: &JsThread<'_>) -> &mut J { + &mut self.0 + } + /// Move the contents out to use and drop them normally (JS thread). + #[inline] + pub fn take(self, _: &JsThread<'_>) -> J { + ManuallyDrop::into_inner(self.0) + } + /// Drop the contents in place (JS thread), leaving `self` logically empty. + /// + /// # Safety + /// `self` is not opened or taken afterwards. + #[inline] + unsafe fn release_in_place(&mut self, _: &JsThread<'_>) { + // SAFETY: fn contract. + unsafe { ManuallyDrop::drop(&mut self.0) } + } +} + +/// A pointer into JS-owned memory (an ArrayBuffer's bytes, a pinned cell, the +/// creating global) that a job carries off-thread. It can be *passed around* +/// anywhere but dereferenced only with proof the VM is alive: a pool +/// [`Borrow`] or a [`JsThread`]. +#[repr(transparent)] +pub struct JsPtr(NonNull); +// SAFETY: dereferenceable only under a Borrow/JsThread (see type doc). +unsafe impl Send for JsPtr {} +impl Clone for JsPtr { + fn clone(&self) -> Self { + *self + } +} +impl Copy for JsPtr {} + +impl JsPtr { + /// # Safety + /// `ptr` stays valid for as long as the VM is alive (the job keeps whatever + /// owns it — an ArrayBuffer, a wrapper — alive from its `Js` side). + #[inline] + pub unsafe fn new(ptr: NonNull) -> Self { + Self(ptr) + } + #[inline] + pub fn as_ptr(self) -> *mut T { + self.0.as_ptr() + } + /// # Safety + /// No other live reference aliases the pointee for `'b`. + #[inline] + #[allow(clippy::mut_from_ref)] // the `&Borrow` is a liveness witness, not the pointee + pub unsafe fn under_borrow<'b>(self, _: &'b Borrow) -> &'b mut T { + // SAFETY: borrow held ⇒ VM alive ⇒ pointee alive (type contract); aliasing per fn contract. + unsafe { &mut *self.0.as_ptr() } + } + /// # Safety + /// No other live reference aliases the pointee for `'b`. + #[inline] + #[allow(clippy::mut_from_ref)] // the `&JsThread` is a thread witness, not the pointee + pub unsafe fn on_js_thread<'b>(self, _: &'b JsThread<'_>) -> &'b mut T { + // SAFETY: JS thread with heap alive; aliasing per fn contract. + unsafe { &mut *self.0.as_ptr() } + } +} + +// ── Job ─────────────────────────────────────────────────────────────────── + +/// What a particular kind of job does. See the module doc for the partition. +pub trait JobContext: Sized + 'static { + type OffThread: Send; + type Js: JsAffine; + + /// Pool thread, under a VM borrow the carrier holds for the whole call. + /// Return `done` to complete now; keep it (e.g. across async I/O that + /// finishes on another thread) and call [`Completion::finish`] later to + /// complete then. Work that outlives this call runs under no borrow and + /// must touch only `off`. + fn run( + off: &mut Self::OffThread, + vm: &Borrow, + done: Completion, + ) -> Option>; + + /// JS thread: the completion. Both partitions are handed over to use and + /// drop normally. + fn then(off: Self::OffThread, js: Self::Js, cx: &JsThread<'_>) -> JsResult<()>; +} + +/// The type-erased head of every [`Job`]: dispatch entries (one task tag +/// serves every `C`) and the VM's live-job links. +#[repr(C)] +pub struct JobHeader { + complete: unsafe fn(*mut JobHeader, &JsThread<'_>) -> JsResult<()>, + release_unrun: unsafe fn(*mut JobHeader, &JsThread<'_>), + release_js: unsafe fn(*mut JobHeader, &JsThread<'_>), + prev: *mut JobHeader, + next: *mut JobHeader, + /// The VM already released this job's JS side (teardown); JS thread only. + js_released: bool, +} + +/// A VM's live jobs (intrusive through [`JobHeader`]); JS thread only, and +/// zero-valid (empty). The pool never touches the links: a job joins at +/// `schedule` and leaves at its completion / release, all on the JS thread. +pub struct JobList { + head: *mut JobHeader, +} + +impl JobList { + fn push(&mut self, job: *mut JobHeader) { + // SAFETY: `job` is a live, unlinked header; JS thread. + unsafe { + (*job).prev = core::ptr::null_mut(); + (*job).next = self.head; + if !self.head.is_null() { + (*self.head).prev = job; + } + } + self.head = job; + } + fn unlink(&mut self, job: *mut JobHeader) { + // SAFETY: `job` is linked in this list; JS thread. + unsafe { + let (prev, next) = ((*job).prev, (*job).next); + if prev.is_null() { + debug_assert!(core::ptr::eq(self.head, job)); + self.head = next; + } else { + (*prev).next = next; + } + if !next.is_null() { + (*next).prev = prev; + } + (*job).prev = core::ptr::null_mut(); + (*job).next = core::ptr::null_mut(); + } + } + /// VM teardown (JS thread, heap alive, script forbidden, before the handle + /// closes): release the JS side of every live job. Whatever the pool still + /// holds afterwards frees only its off-thread part. + pub fn release_all_js(&mut self, cx: &JsThread<'_>) { + let mut job = core::mem::replace(&mut self.head, core::ptr::null_mut()); + while !job.is_null() { + // SAFETY: linked ⇒ live (jobs unlink before they are freed on this + // thread; the pool frees only after the handle closed, i.e. later). + unsafe { + let next = (*job).next; + ((*job).release_js)(job, cx); + (*job).prev = core::ptr::null_mut(); + (*job).next = core::ptr::null_mut(); + job = next; + } + } + } +} + +/// One pool-then-complete job. Heap-allocated by [`Job::schedule`]; freed by +/// exactly one of: its completion on the JS thread, the queue's release at VM +/// teardown (JS thread, heap alive), or — once the VM is gone — the pool +/// thread, which by then has only the off-thread part left to drop. +#[repr(C)] +pub struct Job { + header: JobHeader, + loop_handle: LoopHandle, + task: WorkPoolTask, + keep_alive: JsSide, + off: C::OffThread, + js: JsSide, +} + +impl bun_event_loop::Taskable for Job { + const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::AnyTaskJob; + /// A completion the pool posted whose `then` will not run. (Dispatch goes + /// through the erased header — [`release_unrun_erased`] — since the queue + /// only knows the shared tag; this is the same thing for a known `C`.) + unsafe fn release_unrun(this: *mut Self) { + let vm = VirtualMachine::get(); + // SAFETY: fn contract; JS thread with the heap alive. + unsafe { Self::release_unrun_on(this, &vm.global().js_thread()) } + } +} + +impl Job { + /// JS thread: build the job, keep the loop alive for it, hand it to the pool. + pub fn schedule(cx: &JsThread<'_>, off: C::OffThread, js: C::Js) { + let mut keep_alive = KeepAlive::default(); + keep_alive.ref_(bun_io::js_vm_ctx()); + let job = bun_core::heap::into_raw(Box::new(Self { + header: JobHeader { + // SAFETY: (this and the two entries below) the erased dispatchers + // are only reached through this header, so `p` is this `Job`. + complete: |p, cx| unsafe { Self::complete(p.cast::(), cx) }, + // SAFETY: as above. + release_unrun: |p, cx| unsafe { Self::release_unrun_on(p.cast::(), cx) }, + // SAFETY: as above. + release_js: |p, cx| unsafe { Self::release_js(p.cast::(), cx) }, + prev: core::ptr::null_mut(), + next: core::ptr::null_mut(), + js_released: false, + }, + loop_handle: cx.vm().loop_handle(), + task: WorkPoolTask { + node: Default::default(), + callback: Self::run_on_pool, + }, + keep_alive: JsSide::new(keep_alive, cx), + off, + js: JsSide::new(js, cx), + })); + cx.vm().jobs().push(job.cast()); + // SAFETY: live until one of the three releases; the pool owns it now. + WorkPool::schedule(unsafe { &raw mut (*job).task }); + } + + fn run_on_pool(task: *mut WorkPoolTask) { + // SAFETY: only reachable through the `task.callback` slot wired in + // `schedule`; the pool calls back with exactly that field of a live job. + let this: *mut Self = unsafe { bun_core::from_field_ptr!(Self, task, task) }; + // SAFETY: live job, exclusively the pool's for this callback. + let handle = unsafe { (*this).loop_handle.clone() }; + let done = Completion(NonNull::new(this).expect("job")); + let Some(vm) = handle.borrow() else { + // VM already gone: nothing ran; `finish` releases. + return done.finish(); + }; + // SAFETY: as above; the borrow keeps the VM (and any JsPtr target) alive. + if let Some(done) = C::run(unsafe { &mut (*this).off }, &vm, done) { + drop(vm); + done.finish(); + } + } + + /// JS thread dispatch: run the completion and free the job. + /// + /// # Safety + /// `this` is the job its `Completion` posted; called once. + unsafe fn complete(this: *mut Self, cx: &JsThread<'_>) -> JsResult<()> { + // SAFETY: fn contract. + unsafe { + debug_assert!( + !(*this).header.js_released, + "job dispatched after its VM released it" + ); + cx.vm().jobs().unlink(this.cast()); + let Job { + keep_alive, + off, + js, + .. + } = *Box::from_raw(this); + keep_alive.take(cx).unref(bun_io::js_vm_ctx()); + C::then(off, js.take(cx), cx) + } + } + + /// JS thread, VM tearing down with the heap alive: a completion that was + /// queued but will never dispatch. Everything left is dropped normally. + /// + /// # Safety + /// As [`complete`](Self::complete). + unsafe fn release_unrun_on(this: *mut Self, cx: &JsThread<'_>) { + // SAFETY: fn contract. + unsafe { + if !(*this).header.js_released { + cx.vm().jobs().unlink(this.cast()); + Self::release_js(this, cx); + } + core::ptr::drop_in_place(&raw mut (*this).off); + core::ptr::drop_in_place(&raw mut (*this).loop_handle); + drop(Box::from_raw(this.cast::>())); + } + } + + /// JS thread: drop the JS side (and keep-alive) in place; the job stays + /// allocated for whoever frees the rest. + /// + /// # Safety + /// `this` is live and already unlinked; called at most once. + unsafe fn release_js(this: *mut Self, cx: &JsThread<'_>) { + // SAFETY: fn contract. + unsafe { + debug_assert!(!(*this).header.js_released); + (*this).header.js_released = true; + (*this).js.release_in_place(cx); + let mut keep_alive = core::ptr::read(&raw const (*this).keep_alive).take(cx); + keep_alive.unref(bun_io::js_vm_ctx()); + } + } +} + +impl crate::Postable for Job { + unsafe fn loop_handle(this: *mut Self) -> *const LoopHandle { + // SAFETY: fn contract. + unsafe { &raw const (*this).loop_handle } + } + /// VM gone, pool thread: its teardown already released the JS side and + /// keep-alive on its own thread ([`JobList::release_all_js`]); drop the + /// off-thread part and the handle and free the storage. + unsafe fn release_refused(this: *mut Self) { + // SAFETY: fn contract; refused ⇒ handle closed ⇒ teardown's release ran. + unsafe { + debug_assert!( + (*this).header.js_released, + "VM closed without releasing its jobs" + ); + core::ptr::drop_in_place(&raw mut (*this).off); + core::ptr::drop_in_place(&raw mut (*this).loop_handle); + drop(Box::from_raw(this.cast::>())); + } + } +} + +/// The obligation to complete a running job exactly once: returned from +/// [`JobContext::run`] to complete immediately, or kept and +/// [`finish`](Self::finish)ed later from any thread. Completing delivers the +/// job to its VM (or, if that is gone, releases it). +#[must_use = "a job must be finished exactly once"] +pub struct Completion(NonNull>); +// SAFETY: `finish` only posts the job through its (thread-safe) LoopHandle. +unsafe impl Send for Completion {} +impl Completion { + pub fn finish(self) { + // Consumed: the obligation is met here, so its Drop check must not run. + let job = core::mem::ManuallyDrop::new(self).0.as_ptr(); + // SAFETY: the live heap job this token was created for; consumed once. + unsafe { crate::post_job(job) }; + } + /// The job's off-thread part, for work that continues after `run` returned. + /// + /// # Safety + /// No other reference to it is live (the pool callback has returned). + pub unsafe fn off_thread(&self) -> *mut C::OffThread { + // SAFETY: live job. + unsafe { &raw mut (*self.0.as_ptr()).off } + } +} +impl Drop for Completion { + fn drop(&mut self) { + debug_assert!(false, "job dropped without being finished"); + } +} + +/// Event-loop dispatch for every `Job` (one tag): run its completion. +/// +/// # Safety +/// `ptr` is a `Job` posted by its `Completion` (for some `C`). +pub unsafe fn complete_erased(ptr: *mut (), cx: &JsThread<'_>) -> JsResult<()> { + let header = ptr.cast::(); + // A completion dispatched after the VM was asked to stop (a parent's + // terminate() lands while the worker still ticks): its `then` would only + // build script-facing values under a pending termination. Release it as + // teardown would — Node's threadpool `after` callbacks bail the same way + // on `!can_call_into_js()`. + if !cx.vm().script_allowed() { + // SAFETY: as below; released exactly once, here. + unsafe { ((*header).release_unrun)(header, cx) }; + return Ok(()); + } + // SAFETY: `Job` is `#[repr(C)]` with the header first. + unsafe { ((*header).complete)(header, cx) } +} + +/// Teardown's release for a queued, never-dispatched `Job` completion. +/// +/// # Safety +/// As [`complete_erased`]. +pub unsafe fn release_unrun_erased(ptr: *mut (), cx: &JsThread<'_>) { + let header = ptr.cast::(); + // SAFETY: as above. + unsafe { ((*header).release_unrun)(header, cx) } +} + +const _: () = assert!(core::mem::offset_of!(Job, header) == 0); + +#[doc(hidden)] +pub enum Never {} +impl JobContext for Never { + type OffThread = (); + type Js = (); + fn run(_: &mut (), _: &Borrow, done: Completion) -> Option> { + Some(done) + } + fn then(_: (), _: (), _: &JsThread<'_>) -> JsResult<()> { + Ok(()) + } +} diff --git a/src/jsc/lib.rs b/src/jsc/lib.rs index 36b31ec4878a..0fb901df1940 100644 --- a/src/jsc/lib.rs +++ b/src/jsc/lib.rs @@ -40,7 +40,7 @@ use core::ffi::{c_char, c_void}; // See docs/PORTING.md §JSC types and src/codegen/generate-classes.ts for the // symbol-naming contract the macros uphold. // ────────────────────────────────────────────────────────────────────────── -pub use bun_jsc_macros::{JsClass, codegen_cached_accessors, host_call, host_fn}; +pub use bun_jsc_macros::{JsAffine, JsClass, codegen_cached_accessors, host_call, host_fn}; // ────────────────────────────────────────────────────────────────────────── // Submodules. Each `#[path]` points at the actual PascalCase / snake_case @@ -476,8 +476,6 @@ pub mod bun_heap_profiler; pub mod bun_string_jsc; #[path = "comptime_string_map_jsc.rs"] pub mod comptime_string_map_jsc; -#[path = "ConcurrentPromiseTask.rs"] -pub mod concurrent_promise_task; #[path = "EventLoopHandle.rs"] pub mod event_loop_handle; #[path = "FFI.rs"] @@ -486,8 +484,6 @@ pub mod ffi; pub mod jsc_scheduler; #[path = "ProcessAutoKiller.rs"] pub mod process_auto_killer; -#[path = "WorkTask.rs"] -pub mod work_task; /// Binding for JSCInitialize in ZigGlobalObject.cpp pub fn initialize(eval_mode: bool) { @@ -660,6 +656,27 @@ impl JsResultExt for JsResult { } } +/// The one sanctioned way to turn a `JsResult` into a bare `JSValue`: +/// **only in host-function / getter return position**, where JSC's convention +/// is that an empty value means "the exception is pending on the VM". Anywhere +/// else (a promise settlement, a callback argument, a property store) an empty +/// `JSValue` is not a value — carry the `JsResult` to that boundary instead +/// (`JSPromise::settle`, `?`). `unwrap_or(JSValue::ZERO)` is banned by +/// test/internal/source-lints for that reason. +pub trait HostReturn { + fn or_pending_exception(self) -> JSValue; +} + +impl HostReturn for JsResult { + #[inline] + fn or_pending_exception(self) -> JSValue { + match self { + Ok(v) => v, + Err(_) => JSValue::ZERO, + } + } +} + impl From for JsError { fn from(_: crate::CrateError) -> Self { // Mapping to `Thrown` here lets `?` propagate while the actual throw @@ -1317,8 +1334,13 @@ pub use self::saved_source_map as SavedSourceMap; // ────────────────────────────────────────────────────────────────────────── #[path = "VirtualMachine.rs"] pub mod virtual_machine; +#[path = "VmHandle.rs"] +pub mod vm_handle; pub use self::virtual_machine as VirtualMachine; pub use self::virtual_machine::InitOptions as VirtualMachineInitOptions; +pub use self::vm_handle::{ + ConcurrentPoster, LoopHandle, LoopKind, Postable, Posted, VmHandle, post_job, +}; #[path = "ModuleLoader.rs"] pub mod module_loader; @@ -1355,15 +1377,14 @@ pub use self::js_property_iterator::{ #[path = "event_loop.rs"] pub mod event_loop; pub use self::event_loop as EventLoop; -#[path = "any_task_job.rs"] -pub mod any_task_job; -pub use self::any_task_job::{AnyTaskJob, AnyTaskJobCtx}; +pub mod job; pub use self::event_loop::{ - AnyEventLoop, AnyTaskWithExtraContext, ConcurrentCppTask, ConcurrentPromiseTask, - ConcurrentTask, CppTask, DeferredTaskQueue, EventLoopHandle, EventLoopTask, EventLoopTaskPtr, - GarbageCollectionController, JsTerminated, JsTerminatedResult, ManagedTask, MiniEventLoop, - PosixSignalHandle, PosixSignalTask, Task, WorkPool, WorkPoolTask, WorkTask, WorkTaskContext, + AnyEventLoop, AnyTaskWithExtraContext, ConcurrentCppTask, ConcurrentTask, CppTask, + DeferredTaskQueue, EventLoopHandle, EventLoopTask, GarbageCollectionController, JsTerminated, + JsTerminatedResult, ManagedTask, MiniEventLoop, PosixSignalHandle, PosixSignalTask, Task, + WorkPool, WorkPoolTask, }; +pub use self::job::{Completion, Job, JobContext, JsPtr, JsSide, JsThread, Protected}; #[cfg(unix)] pub type PlatformEventLoop = bun_uws::Loop; #[cfg(not(unix))] diff --git a/src/jsc/modules/BunTestModule.h b/src/jsc/modules/BunTestModule.h index b5c5c84b6701..5e788ba9078e 100644 --- a/src/jsc/modules/BunTestModule.h +++ b/src/jsc/modules/BunTestModule.h @@ -28,7 +28,9 @@ void generateNativeModule_BunTest( JSC::PropertySlot slot(object, JSC::PropertySlot::InternalMethodType::Get); auto ownPropertySlot = object->methodTable()->getOwnPropertySlot(object, lexicalGlobalObject, property, slot); if (topExceptionScope.exception()) [[unlikely]] { - (void)topExceptionScope.tryClearException(); + if (!topExceptionScope.tryClearException()) + return; // termination: leave it pending + continue; } if (ownPropertySlot) { exportNames.append(property); diff --git a/src/jsc/modules/NodeModuleModule.cpp b/src/jsc/modules/NodeModuleModule.cpp index a5d4b6d45869..520619e9abe9 100644 --- a/src/jsc/modules/NodeModuleModule.cpp +++ b/src/jsc/modules/NodeModuleModule.cpp @@ -730,9 +730,15 @@ JSC_DEFINE_CUSTOM_GETTER(nodeModuleWrapper, jsFunctionSetCJSWrapperItem, JSC::ImplementationVisibility::Public, JSC::NoIntrinsic)); + auto scope = DECLARE_THROW_SCOPE(vm); NakedPtr returnedException = nullptr; auto result = JSC::profiledCall(global, JSC::ProfilingReason::API, cb, callData, JSC::jsUndefined(), args, returnedException); - ASSERT(!returnedException); + if (returnedException) { + // The builtin does not throw on its own; what comes back is a + // termination (or stack exhaustion) that the getter's caller must see. + JSC::throwException(global, scope, returnedException.get()); + return {}; + } ASSERT(result.isCell()); return JSC::JSValue::encode(result); } @@ -1245,8 +1251,11 @@ void generateNativeModule_NodeModule(JSC::JSGlobalObject* lexicalGlobalObject, JSValue value = constructor->get(globalObject, property); if (topExceptionScope.exception()) [[unlikely]] { - value = {}; - (void)topExceptionScope.tryClearException(); + // A termination (worker terminate() mid-import) cannot be cleared: + // stop the walk and leave it pending for the loader. + if (!topExceptionScope.tryClearException()) + return; + value = jsUndefined(); } exportNames.append(property); diff --git a/src/jsc/modules/NodeProcessModule.h b/src/jsc/modules/NodeProcessModule.h index 80ac581d48c2..0f4c87a6e189 100644 --- a/src/jsc/modules/NodeProcessModule.h +++ b/src/jsc/modules/NodeProcessModule.h @@ -33,14 +33,17 @@ DEFINE_NATIVE_MODULE(NodeProcess) continue; } - exportNames.append(entry); auto topExceptionScope = DECLARE_TOP_EXCEPTION_SCOPE(vm); JSValue result = process->get(globalObject, entry); if (topExceptionScope.exception()) { + // A getter that throws exports undefined; a termination (worker + // terminate() mid-import) cannot be cleared: stop, leave it pending. + if (!topExceptionScope.tryClearException()) + return; result = jsUndefined(); - (void)topExceptionScope.tryClearException(); } + exportNames.append(entry); exportValues.append(result); } } diff --git a/src/jsc/modules/ObjectModule.cpp b/src/jsc/modules/ObjectModule.cpp index 442e1e4f7a9d..b24970a2ae2b 100644 --- a/src/jsc/modules/ObjectModule.cpp +++ b/src/jsc/modules/ObjectModule.cpp @@ -22,14 +22,14 @@ generateObjectModuleSourceCode(JSC::JSGlobalObject* globalObject, gcUnprotectNullTolerant(object); for (auto& entry : properties.releaseData()->propertyNameVector()) { - exportNames.append(entry); - auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); JSValue value = object->get(globalObject, entry); if (scope.exception()) [[unlikely]] { - (void)scope.tryClearException(); + if (!scope.tryClearException()) + return; // termination: leave it pending value = jsUndefined(); } + exportNames.append(entry); exportValues.append(value); } }; diff --git a/src/jsc/node_path.rs b/src/jsc/node_path.rs index 84e7d12b6d05..8603ae36b8bb 100644 --- a/src/jsc/node_path.rs +++ b/src/jsc/node_path.rs @@ -53,6 +53,12 @@ impl ThreadSafe { } } +// SAFETY: this is what the type asserts — the JS-backed views inside `T` are +// GC-protected for as long as it is held, so they may be read from another +// thread (under that thread's VM borrow); the protection itself is released +// only on a JS thread (see `Drop`). +unsafe impl Send for ThreadSafe {} + impl core::ops::Deref for ThreadSafe { type Target = T; #[inline] @@ -71,7 +77,11 @@ impl core::ops::DerefMut for ThreadSafe { impl Drop for ThreadSafe { #[inline] fn drop(&mut self) { - self.0.unprotect(); + // The protection is engine state: released here on a JS thread, gone + // with the heap anywhere else (a pool thread releasing a dead VM's job). + if crate::virtual_machine::VirtualMachine::get_or_null().is_some() { + self.0.unprotect(); + } // `self.0: T` drops next (field drop after `Drop::drop`). } } diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index 91712de44360..8476ef60cb1b 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -34,7 +34,7 @@ use super::uuid::UUID; // - `cron_jobs` / `node_fs_stat_watcher_scheduler` // → erased `*mut c_void` slots; high tier lazy-inits. // - the `bun test --isolate` watcher/server registries → moved to -// `bun_runtime::jsc_hooks::IsolationHandles` so the entries keep their +// `bun_runtime::jsc_hooks::ActiveHandles` so the entries keep their // concrete types. // - `stdin/stdout/stderr_store` → erased `*mut blob::Store` constructed via // `__bun_stdio_blob_store_new` (link-time extern). @@ -858,18 +858,20 @@ impl RareData { } // ── close_all_socket_groups ─────────────────────────────────────────── - /// Drain every embedded socket group. Must run BEFORE JSC teardown — closeAll - /// fires on_close → JS callbacks → needs a live VM. RareData.deinit() runs - /// after `WebWorker__teardownJSCVM`, so doing the closeAll - /// there would dispatch into freed JSC heap. - pub(crate) fn close_all_socket_groups(&mut self, vm: &VirtualMachine) { - // closeAll() dispatches on_close into JS while the VM is still alive, so a - // handler can call Bun.connect/postgres/etc. and re-populate a group we - // just drained. Loop until every group is observed empty in the same pass - // (bounded — each retry only happens if a JS callback opened a *new* - // socket, and the cap stops a deliberately-spinning on_close from wedging - // teardown; the post-close force-drain in close_all handles whatever's - // left after the cap). + /// Drain every embedded socket group. Runs in teardown's stop phase (script + /// already forbidden) and must run BEFORE JSC teardown: native close paths + /// release Strong handles and touch heap-owned state. RareData.deinit() runs + /// after `WebWorker__teardownJSCVM`, so doing the closeAll there would touch + /// freed JSC heap. + pub(crate) fn close_all_socket_groups( + &mut self, + vm: &VirtualMachine, + ) -> crate::virtual_machine::SweepResult { + // A native close path can cascade (closing one socket completes or + // fails another, whose own close lands in a group already drained), so + // loop until every group is observed empty in the same pass — bounded; + // the post-close force-drain in close_all handles whatever is left + // after the cap. // Walk the loop's linked-group list rather than just our 14 embedded // fields: Listener/uWS-App groups own their own SocketGroup, and accepted // sockets land *there*, not in RareData. Iterating only the embedded @@ -886,12 +888,18 @@ impl RareData { } rounds += 1; } + let result = if rounds > 0 { + crate::virtual_machine::SweepResult::Stopped + } else { + crate::virtual_machine::SweepResult::Idle + }; // us_socket_close pushes to loop->data.closed_head; loop_post() normally // frees it on the next tick. We're past the last tick, so drain it now — // every us_socket_t is libc-allocated and otherwise becomes an LSAN leak // (the only pointer into it lives in mimalloc-backed RareData, which LSAN // can't trace once we unregister the root region). vm.uws_loop_mut().drain_closed_sockets(); + result } } @@ -1093,6 +1101,15 @@ impl Drop for RareData { __bun_stdio_blob_store_deinit(store.as_ptr().cast()); } + self.detach_socket_groups_from_loop(); + } +} + +impl RareData { + /// Detach every embedded socket group from the thread's uSockets loop + /// (asserting each is empty). A thread teardown calls this before it frees + /// that loop; `Drop` calls it for every other owner. Idempotent. + pub(crate) fn detach_socket_groups_from_loop(&mut self) { // closeAllSocketGroups() must have already run (before JSC teardown) so // these are empty; deinit() asserts that in debug. for_each_socket_group!(self, |g| { @@ -1106,6 +1123,7 @@ impl Drop for RareData { // loop has already unlinked it (close_all_socket_groups ran), // so destroy reduces to the empty-list debug asserts. unsafe { SocketGroup::destroy(std::ptr::from_mut::(g)) }; + g.loop_ = core::ptr::null_mut(); } }); } diff --git a/src/jsc/resolver_jsc.rs b/src/jsc/resolver_jsc.rs index 3318f9aaf687..1a0a7f5e7617 100644 --- a/src/jsc/resolver_jsc.rs +++ b/src/jsc/resolver_jsc.rs @@ -1,6 +1,7 @@ //! Host fns / C++ exports for `node:module` `_nodeModulePaths`. Lives here so //! `resolver/` has no JSC references. +use crate::HostReturn as _; use bstr::BStr; use crate::{CallFrame, JSGlobalObject, JSValue, JsResult}; @@ -118,7 +119,7 @@ extern "C" fn node_module_paths_js_value( OwnedString::as_raw_slice(&list) .to_js_array(global) - .unwrap_or(JSValue::ZERO) + .or_pending_exception() } /// `[bun.String]::to_js_array` lives on the `StringArrayJsc` ext trait below. diff --git a/src/jsc/virtual_machine_exports.rs b/src/jsc/virtual_machine_exports.rs index 97531e611ef6..c81f5f148d68 100644 --- a/src/jsc/virtual_machine_exports.rs +++ b/src/jsc/virtual_machine_exports.rs @@ -1,6 +1,5 @@ use core::ffi::c_void; -use crate::event_loop::ConcurrentTask; use crate::plugin_runner::PluginRunner; use crate::{ CallFrame, JSGlobalObject, JSPromise, JSValue, JsResult, Strong, Task, @@ -72,17 +71,6 @@ pub fn is_bun_main(global: &JSGlobalObject, str: &BunString) -> bool { str.eql_utf8(global.bun_vm().as_mut().main()) } -/// This function is called on the main thread -/// The bunVM() call will assert this -// HOST_EXPORT(Bun__queueTask, c) -pub fn queue_task(global: &JSGlobalObject, task: *mut crate::cpp_task::CppTask) { - crate::mark_binding!(); - global - .bun_vm() - .event_loop_mut() - .enqueue_task(Task::init(task)); -} - // HOST_EXPORT(Bun__reportUnhandledError, c) pub fn report_unhandled_error(global: &JSGlobalObject, value: JSValue) -> JSValue { crate::mark_binding!(); @@ -96,19 +84,34 @@ pub fn report_unhandled_error(global: &JSGlobalObject, value: JSValue) -> JSValu JSValue::UNDEFINED } -/// This function is called on another thread -/// The main difference: we need to allocate the task & wakeup the thread -/// We can avoid that if we run it from the main thread. -// HOST_EXPORT(Bun__queueTaskConcurrently, c) -pub fn queue_task_concurrently(global: &JSGlobalObject, task: *mut crate::cpp_task::CppTask) { +/// `ScriptExecutionContext::postTask` — the context addresses the thread's VM +/// directly because it outlives the `Zig::GlobalObject` it was created with. +// HOST_EXPORT(Bun__VM__queueTask, c) +pub fn vm_queue_task(this: &VirtualMachine, task: *mut crate::cpp_task::CppTask) { crate::mark_binding!(); - // SAFETY: bun_vm_concurrently() yields the live VM; `event_loop()` never - // returns null for a Bun-owned global. Called off-thread but the loop - // wakeup is thread-safe. - unsafe { - (*(*global.bun_vm_concurrently()).event_loop()) - .enqueue_task_concurrent(ConcurrentTask::create(Task::init(task))); - } + this.event_loop_mut().enqueue_task(Task::init(task)); +} + +/// [`vm_queue_task`] for a task that must let the loop poll I/O and timers +/// first (a drain re-posting its own continuation). +// HOST_EXPORT(Bun__VM__queueTaskAfterYield, c) +pub fn vm_queue_task_after_yield(this: &VirtualMachine, task: *mut crate::cpp_task::CppTask) { + crate::mark_binding!(); + this.event_loop_mut() + .enqueue_task_after_yield(Task::init(task)); +} + +/// Off-thread counterpart of [`vm_queue_task`]: see [`crate::VmHandle::post_cpp_task`]. +// HOST_EXPORT(Bun__VmHandle__queueTaskConcurrently, c) +#[allow(clippy::not_unsafe_ptr_arg_deref)] // the C ABI boundary is the unsafe part +pub fn vm_handle_queue_task_concurrently( + r: *const crate::vm_handle::Shared, + task: *mut crate::cpp_task::CppTask, +) { + crate::mark_binding!(); + // SAFETY: C++ passes the reference its ScriptExecutionContext holds, and + // hands over a live heap EventLoopTask. + unsafe { crate::VmHandle::borrow_ref(r).post_cpp_task(task) }; } // HOST_EXPORT(Bun__handleRejectedPromise, c) diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 90d0b0daab0b..3a1ae1cce08f 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1,184 +1,118 @@ -//! Shared implementation of Web and Node `Worker`. +//! The thread that runs a Worker's global scope. //! -//! Lifetime / threading model -//! ========================== +//! One `WebWorker` per worker thread. It is atomically refcounted and does not +//! belong to either side alone: //! -//! Three objects, two threads, one ownership rule: +//! - the C++ `WorkerMessagingProxy` (the parent<->worker relationship object, +//! see WorkerMessagingProxy.h) holds one ref from `create()` until it has +//! joined the thread (`releaseWorkerThread()`), and +//! - the running thread holds one for the whole of `thread_main`. //! -//! ┌─ PARENT THREAD ───────────────────────────────────────────────────────┐ -//! │ JSWorker (GC'd JSCell) ──Ref──► WebCore::Worker (ThreadSafeRefCounted)│ -//! │ └─ impl_ ──owns──► WebWorker │ -//! └───────────────────────────────────────────────────────┬───────────────┘ -//! │ -//! ┌─ WORKER THREAD ───────────────────────────────────────┴───────────────┐ -//! │ runs threadMain() → spin() → shutdown(); reads this struct directly │ -//! └───────────────────────────────────────────────────────────────────────┘ +//! `proxy` points back at the messaging proxy, which the thread also holds a +//! ref on, so it is valid for the thread's whole life. Everything the thread +//! wants to tell the parent goes through it by context id; nothing here ever +//! touches the parent's `Worker` object or a thread-affine ref. //! -//! Ownership rule: this struct is OWNED BY the C++ `WebCore::Worker`. It is -//! allocated in `create()` and freed in `WebCore::Worker::~Worker()` via -//! `WebWorker__destroy`. The worker thread NEVER frees it. Because `JSWorker` -//! holds a `Ref`, `impl_` is valid for the entire time JS can call -//! `terminate()`/`ref()`/`unref()` — those calls cannot UAF. +//! Thread lifecycle (`thread_main`): +//! 1. `start_vm()` — arena, cloned env, `VirtualMachine`, publish `vm` under `vm_lock`. +//! 2. `spin()` — load the entry point, `workerGlobalScopeStarted`, run the +//! event loop until it drains or termination is requested, +//! `beforeExit` on a natural drain. +//! 3. `shutdown()` — 'exit' handlers, stop phase, join own children, JSC VM +//! teardown, free per-thread state, `workerGlobalScopeDestroyed`. +//! Then the thread drops its self-ref and returns; the parent joins it. //! -//! Refs on `WebCore::Worker`: -//! - `JSWorker` wrapper +1 (dropped at GC) -//! - worker thread +1 taken in `Worker::create()` BEFORE the thread is -//! spawned, dropped on the PARENT thread inside the -//! close task posted by `dispatchExit()`. `~Worker` -//! therefore never runs on the worker thread. +//! Children: every worker created on a thread is registered on that thread's +//! `VirtualMachine.child_workers` (parent thread only). When a thread exits — +//! the main thread in `global_exit`, a worker in `shutdown()` — its stop phase +//! has already asked each child to terminate; it then joins each child and +//! performs the parent-side release itself (`parentContextWillDestroy`). This +//! is Node's `stop_sub_worker_contexts()`; there is no process-global list. //! -//! Lifecycle of the worker thread (`threadMain`): -//! 1. `startVM()` — build a mimalloc arena, clone env, initialise a -//! `jsc.VirtualMachine`, publish `vm` under `vm_lock`. -//! 2. `spin()` — load the entry point, call `dispatchOnline` + -//! `fireEarlyMessages`, run the event loop until it drains or -//! `requested_terminate` is observed, run `beforeExit`. -//! 3. `shutdown()` — call `vm.onExit()`, tear down the JSC VM, post -//! `dispatchExit` (which releases `parent_poll_ref` + the thread ref on -//! the parent), free the arena, exit the thread. After `dispatchExit` -//! `this` may be freed at any time; nothing below it dereferences `this`. -//! -//! `vm_lock` exists solely to close the TOCTOU between the parent reading a -//! non-null `vm` (in `notifyNeedTermination`) and the worker freeing the arena -//! that backs it. It is held only while (a) publishing `vm` in `startVM`, -//! (b) nulling `vm` in `shutdown`, (c) reading `vm` + calling `wakeup()` in -//! `notifyNeedTermination`. -//! -//! Every field below is grouped by which thread may touch it. -//! -//! At process exit (`globalExit` under BUN_DESTRUCT_VM_ON_EXIT), -//! `terminateAllAndWait()` stops every live worker and waits for each to -//! reach `shutdown()` before process-global resolver state is freed — the -//! main-thread analogue of Node's `Environment::stop_sub_worker_contexts()`. -//! -//! Known gap vs Node.js: the worker thread is detached, not joined, so -//! `await worker.terminate()` resolves before the OS thread is fully gone; -//! nested workers are not stopped when their WORKER parent's context tears -//! down (only the main thread waits). When a parent context is gone before -//! the close task posts, the thread-held `Worker` ref is intentionally -//! leaked (see `Worker::dispatchExit`). +//! `vm_lock` closes the TOCTOU between another thread reading a non-null `vm` +//! (to raise a TerminationException / wake the loop) and this thread freeing +//! it: held while publishing, while unpublishing, and around that read. use crate::JsCell; use core::cell::Cell; use core::ffi::c_void; use core::ptr::NonNull; -use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use core::sync::atomic::{AtomicBool, Ordering}; +use std::thread::JoinHandle; use bun_core::{String as BunString, WTFStringImpl}; use bun_io::KeepAlive; -use bun_threading::{Futex, Mutex}; +use bun_threading::Mutex; use crate::virtual_machine::{self, VirtualMachine, runtime_hooks}; use crate::{self as jsc, JSGlobalObject, JSValue, JsError, LogJsc}; bun_core::define_scoped_log!(log, Worker, hidden); -// ---- Immutable after `create()` (safe from any thread) ---------------------- - +#[derive(bun_ptr::ThreadSafeRefCounted)] pub struct WebWorker { - /// The owning C++ `WebCore::Worker`. Never null; this struct is freed by - /// `~Worker`, so the pointer cannot dangle. - cpp_worker: *mut c_void, - /// Parent `jsc.VirtualMachine`. Read on the worker thread by `startVM()` - /// (transform options, env, proxy storage, standalone graph) and on the - /// parent thread by `setRef()` / `releaseParentPollRef()`. - /// - /// Validity: when the parent is the main thread, `globalExit()` calls - /// `terminateAllAndWait()` before freeing anything, so this stays valid - /// through `startVM()` even with `{ref:false}`/`.unref()`. When the parent - /// is itself a worker, nothing joins us on its exit — the nested-worker - /// "Known gap" in the file header. When `parent_poll_ref` is held (the - /// default), the parent's loop stays alive until the close task runs. - // `BackRef` (not `&'a VirtualMachine`) because the struct is FFI-owned and - // crosses threads; the backref invariant (parent outlives child via - // `parent_poll_ref`) is documented above. + // ---- Immutable after `create()` (any thread) ---------------------------- + /// The C++ `WorkerMessagingProxy`; the thread holds a ref on it, so it is + /// valid for as long as this thread runs. Opaque here. + messaging_proxy: *mut c_void, + /// The `VirtualMachine` of the thread that created this worker. Read on the + /// worker thread by `start_vm()` (transform options, env, standalone graph) + /// and on the parent thread for `parent_poll_ref` / `child_workers`. Valid + /// because a parent joins its children before its own VM is destroyed. parent: bun_ptr::BackRef, execution_context_id: u32, mini: bool, eval_mode: bool, store_fd: bool, - /// Borrowed from C++ `WorkerOptions` (kept alive by the owning `Worker`). + /// Borrowed from the proxy's `WorkerOptions` (alive as long as the proxy). argv_ptr: *const WTFStringImpl, argv_len: usize, exec_argv_ptr: *const WTFStringImpl, exec_argv_len: usize, inherit_exec_argv: bool, - /// Heap-owned by this struct; freed in `destroy()`. unresolved_specifier: Box<[u8]>, preloads: Vec>, - /// Owned NUL-terminated bytes. name: bun_core::ZBox, - // ---- Cross-thread signalling -------------------------------------------- - /// Intrusive node for the process-global `LiveWorkers` list. Registered - /// before the thread is spawned; removed in `shutdown()` once the worker is - /// past all process-global resolver access. - /// - /// `Cell` because `terminate_all_and_wait` walks the list through - /// `&WebWorker` while `register`/`unregister` (under `live_workers::MUTEX`) - /// write these on another thread — the mutex serialises memory ops, but - /// Rust's aliasing model still requires interior mutability. `*mut T` is - /// `Copy`, so `Cell` (not `UnsafeCell`) suffices and every read/write is - /// safe `.get()`/`.set()`. - live_next: Cell<*mut WebWorker>, - live_prev: Cell<*mut WebWorker>, - - /// Set by the parent (`notifyNeedTermination`) or by the worker itself - /// (`exit`). The worker loop polls this between ticks. + // ---- Cross-thread ---------------------------------------------------------- + ref_count: bun_ptr::ThreadSafeRefCount, + /// Set by the parent (`requestTermination`), by an exiting ancestor, or by + /// the worker itself (`process.exit()`); polled by the worker loop between + /// ticks and turned into a JSC TerminationException for running script. requested_terminate: AtomicBool, - - /// The worker's `jsc.VirtualMachine`, or null before `startVM()` / after - /// `shutdown()` nulls it. Lives inside `arena`. `vm_lock` must be held for - /// any cross-thread read (see header comment). - /// - /// `Cell` because this is read through `&WebWorker` on the parent / main - /// thread (`notify_need_termination`, `terminate_all_and_wait`, `exit`) and - /// written on the worker thread (`start_vm`, `shutdown`) — `vm_lock` - /// serialises the memory ops, but Rust's aliasing model still requires - /// interior mutability for a field written while a `&WebWorker` may be - /// live. `*mut T` is `Copy`, so `Cell` gives safe `.get()`/`.set()`/ - /// `.replace()` and no `unsafe` at the access sites. + /// The worker's `VirtualMachine`, null before `start_vm()` publishes it and + /// after `shutdown()` unpublishes it. Cross-thread readers hold `vm_lock`. vm: Cell<*mut VirtualMachine>, vm_lock: Mutex, - // ---- Parent-thread only ------------------------------------------------- - /// Keep-alive on the parent's event loop. `Async.KeepAlive` is not - /// thread-safe; it is reffed in `create()`, toggled by `setRef()` (JS - /// `.ref()`/`.unref()`), and released by `releaseParentPollRef()` from the - /// close task — all on the parent thread. - /// - /// `JsCell` because all parent-thread FFI exports take `*mut WebWorker` - /// (the worker thread may concurrently hold `&WebWorker`); we mutate this - /// field through a shared-provenance pointer. Parent-thread-only access - /// satisfies `JsCell`'s single-owner-thread invariant (same as `arena` - /// below for the worker thread). + // ---- Parent-thread only --------------------------------------------------- + /// Keep-alive on the parent's event loop: taken in `create()`, toggled by + /// `.ref()`/`.unref()`, released when the parent releases the thread. parent_poll_ref: JsCell, + /// Taken by the parent to join the OS thread. + join_handle: JsCell>>, - // ---- Worker-thread only ------------------------------------------------- - // These are mutated only on the worker thread, but the worker-thread call - // chain takes `&self` (NOT `&mut self`) because the parent / main thread - // may concurrently hold `&WebWorker` (`notify_need_termination`, - // `terminate_all_and_wait`); materialising `&mut WebWorker` on the worker - // thread while another thread holds `&WebWorker` is aliased-&mut UB. Hence - // `Cell` / `UnsafeCell` even for single-threaded data. + // ---- Worker-thread only ----------------------------------------------------- + // Mutated only on the worker thread, but through `&self` because other + // threads hold `&WebWorker` concurrently; hence the cells. status: Cell, - // Kept as an explicit arena (rather than the global allocator) because - // the VM's allocator IS this arena (load-bearing). - // `JsCell` (not `Cell`) because `Arena` is non-`Copy`; worker-thread-only - // so the single-owner-thread invariant `JsCell` documents is upheld. + // The VM's allocator IS this arena. arena: JsCell>, - /// Heap-owned cloned env for the worker VM. The worker `Arena` - /// (`bumpalo::Bump`) does not run `Drop` (so the inner `HashTable` would - /// leak), and `clone_with_allocator()` does not route through the arena - /// allocator anyway — own it as a `Box` here instead. `start_vm()` - /// `heap::alloc`s and stores the pointer; `shutdown()` step 5 - /// `heap::take`s after `vm.destroy()`. + /// Cloned env for the worker VM; boxed on the global heap because the arena + /// does not run `Drop`. Reclaimed in `shutdown()`. worker_env_loader: Cell<*mut bun_dotenv::Loader>, - /// Set by `exit()` so that `spin()`'s error paths don't clobber an explicit - /// `process.exit(code)`. Atomic so `exit()` can take `&self` (the struct is - /// observed concurrently by `terminate_all_and_wait` / parent-thread FFI; - /// producing `&mut WebWorker` while another thread holds `&WebWorker` is UB). + /// `process.exit(code)` ran; later error paths must not overwrite its code. exit_called: AtomicBool, + /// The parent asked this thread to stop (`worker.terminate()` or an exiting + /// parent) while its VM was live — as opposed to the thread stopping itself, + /// or being stopped before it started. Written under `vm_lock`. + terminated_by_parent: AtomicBool, +} + +enum EntryOutcome { + Continue, + /// The entry module rejected and no handler took it: the worker exits. + Stop, } #[repr(u8)] @@ -188,221 +122,74 @@ pub enum Status { Start, /// `spin()` has begun; entry point is loading. Starting, - /// `dispatchOnline` has fired; event loop is running. + /// `workerGlobalScopeStarted` has fired; event loop is running. Running, /// `shutdown()` has begun; no further JS will run. Terminated, } -// `JSGlobalObject` is an opaque FFI handle (ZST); per codebase convention -// (see JSGlobalObject.rs externs) it crosses FFI as `*const` even when C++ -// mutates — Rust never reads/writes bytes through it, so no `*mut` needed. +// `JSGlobalObject` is an opaque FFI handle (ZST); it crosses FFI as `&`/`*const` +// even when C++ mutates through it. `proxy` is the opaque C++ `WorkerMessagingProxy*` +// round-tripped from `create()`; it is only ever handed back to C++. unsafe extern "C" { - // safe: `JSGlobalObject` is an opaque `UnsafeCell`-backed ZST handle (`&` is - // ABI-identical to non-null `*const`); C++ mutating VM state through it is - // interior to the cell. - safe fn WebWorker__teardownJSCVM(global: &JSGlobalObject); - // safe: opaque `&JSGlobalObject` handle (see above); takes the contexts-map - // lock and flips an atomic flag, no Rust-visible state touched. - safe fn ScriptExecutionContext__markTerminating(global: &JSGlobalObject); - // safe: same opaque-handle contract; flips JSCTaskScheduler::m_isShuttingDown - // under its own lock and returns. Idempotent. - safe fn Bun__JSCTaskScheduler__markShuttingDown(global: &JSGlobalObject); - // safe: `cpp_worker` is an opaque round-trip pointer owned by C++ (allocated - // there, stored in `WebWorker.cpp_worker`, and only ever passed back to C++ - // — never dereferenced as Rust data); same contract as `JSC__VM__holdAPILock`'s - // `ctx`. `&JSGlobalObject` is the non-null handle proof; remaining args are - // by-value scalars/`#[repr(C)]` PODs. - safe fn WebWorker__dispatchExit(cpp_worker: *mut c_void, exit_code: i32); - // safe: no args; frees this thread's lazily-allocated HPACK scratch buffer. - safe fn Bun__freeSharedHeaderBufferForThreadExit(); - // Re-declared here (also private in VM.rs) so `thread_main` can take the - // API lock as a raw FFI call with NO RAII guard — see the note there. - safe fn JSC__VM__getAPILock(vm: &jsc::VM); - safe fn WebWorker__dispatchOnline(cpp_worker: *mut c_void, global: &JSGlobalObject); - safe fn WebWorker__fireEarlyMessages(cpp_worker: *mut c_void, global: &JSGlobalObject); + safe fn WebWorker__workerGlobalScopeStarted(proxy: *mut c_void, global: &JSGlobalObject); + safe fn WebWorker__workerGlobalScopeDestroyed( + proxy: *mut c_void, + exit_code: i32, + stopped_by_parent: bool, + ); + safe fn WebWorker__parentContextWillDestroy(proxy: *mut c_void); safe fn WebWorker__entrySettled(global: &JSGlobalObject); safe fn WebWorker__dispatchError( global: &JSGlobalObject, - cpp_worker: *mut c_void, + proxy: *mut c_void, message: &mut BunString, err: JSValue, ); + safe fn Bun__freeSharedHeaderBufferForThreadExit(); + // Raw FFI (no RAII guard) so `thread_main` can take the API lock and abandon + // it with the VM — see the note there. + safe fn JSC__VM__getAPILock(vm: &jsc::VM); } -/// Process-global registry of worker threads that have been spawned and -/// have not yet reached the point in `shutdown()` where they are past all -/// process-global resolver access (BSSMap singletons like `dir_cache`). -/// `globalExit()` uses this to terminate and wait for workers before -/// `transpiler.deinit()` frees those singletons. -/// -/// Lock ordering: `LiveWorkers.mutex` → `worker.vm_lock` (never the reverse). -mod live_workers { - use super::*; - - pub(super) static MUTEX: Mutex = Mutex::new(); - // Intrusive doubly-linked list head; nodes are `WebWorker.live_{next,prev}`. - // PORTING.md §Global mutable state: list head, every read/write is under - // `MUTEX` above. `AtomicCell` so the slot itself is `Sync` with safe - // load/store (the mutex still provides the actual happens-before for the - // intrusive list walk). - pub(super) static HEAD: bun_core::AtomicCell<*mut WebWorker> = - bun_core::AtomicCell::new(core::ptr::null_mut()); - /// Number of workers registered in `list`. Separate atomic so - /// `terminateAllAndWait` can futex-wait on it without the mutex. - pub(super) static OUTSTANDING: AtomicU32 = AtomicU32::new(0); - - pub(super) fn register(worker: *mut WebWorker) { - MUTEX.lock(); - let head = HEAD.load(); - // SAFETY: MUTEX held; `worker` is a valid heap allocation owned by C++. - unsafe { - (*worker).live_prev.set(core::ptr::null_mut()); - (*worker).live_next.set(head); - if !head.is_null() { - (*head).live_prev.set(worker); - } - } - HEAD.store(worker); - // fetch_add and wake MUST happen under MUTEX so that `terminate_all_and_wait` - // can never observe the worker in the list while OUTSTANDING is still - // at its pre-increment value — otherwise it could sweep B, see - // OUTSTANDING==0 (A's unregister already ran, B's add hasn't), and - // return early while B is still starting. - OUTSTANDING.fetch_add(1, Ordering::Release); - // Wake terminateAllAndWait so it re-sweeps and catches this worker - // (it may have been created by another worker mid-sweep). No-op if - // nothing is waiting. - Futex::wake(&OUTSTANDING, 1); - MUTEX.unlock(); - } - - // `*const WebWorker` (not `*mut`): called from `shutdown(&self)` while - // other threads may hold `&WebWorker`, so the caller only has shared-ref - // provenance. All writes here go through `Cell` fields - // (`live_next`/`live_prev`), which is sound via shared provenance. - pub(super) fn unlink(worker: *const WebWorker) { - MUTEX.lock(); - // SAFETY: MUTEX held; node was registered in `register`. - unsafe { - let prev = (*worker).live_prev.get(); - let next = (*worker).live_next.get(); - if !prev.is_null() { - (*prev).live_next.set(next); - } else { - HEAD.store(next); - } - if !next.is_null() { - (*next).live_prev.set(prev); - } - (*worker).live_prev.set(core::ptr::null_mut()); - (*worker).live_next.set(core::ptr::null_mut()); - } - MUTEX.unlock(); - } - - /// Decrement `OUTSTANDING` and wake `terminate_all_and_wait`. Split from - /// `unlink` so `shutdown()` can defer it until after `dispatchExit` has - /// posted the close task — guaranteeing `global_exit` observes that task - /// before draining the parent's concurrent queue. Touches no `WebWorker` - /// state, so it is safe even if `self` has already been freed. - pub(super) fn mark_exited() { - // Wake any waiter in terminateAllAndWait when we hit zero. Waking - // unconditionally is fine (spurious wakeups just re-check the - // counter) and avoids a compare-before-wake race. - OUTSTANDING.fetch_sub(1, Ordering::Release); - Futex::wake(&OUTSTANDING, 1); - } - - pub(super) fn unregister(worker: *const WebWorker) { - unlink(worker); - mark_exited(); - } -} - -/// Request termination of every live worker and block until each has reached -/// `shutdown()` (past all process-global resolver access), or `timeout_ms` -/// elapses. Called from `VirtualMachine.globalExit()` on the main thread -/// before `transpiler.deinit()` frees the process-global BSSMap singletons — -/// without this, a detached worker still in `startVM()`/`spin()` would UAF on -/// `dir_cache` / `dirname_store` etc. -/// -/// This is the `Environment::stop_sub_worker_contexts()` equivalent for the -/// main thread; nested workers (a worker's own sub-workers at the worker's -/// exit) remain the documented gap. -/// -/// Termination is cooperative: `requested_terminate` is polled at -/// checkpoints throughout `startVM()` and `spin()`, and for a running VM -/// `notifyNeedTermination()` raises a TerminationException at the next JSC -/// safepoint. We do NOT use `thread_suspend`/`SuspendThread` — a worker -/// frozen mid-mimalloc-alloc or holding the `dir_cache` mutex would -/// deadlock/corrupt the very cleanup we're trying to make safe. -pub fn terminate_all_and_wait(timeout_ms: u64) { - if live_workers::OUTSTANDING.load(Ordering::Acquire) == 0 { - return; - } - - // Futex-wait on the counter so we sleep rather than burn a core. Each - // unregister() wakes us; we re-check and re-wait until zero or deadline. - // We re-sweep the list on EVERY iteration: a worker A that was mid- - // `WebWorker__create` for a nested worker B when we first swept will - // register B after we release the mutex, and B's `requested_terminate` - // was never set. Sweeping is O(outstanding) and `requested_terminate` - // is a swap, so re-sweeping already-terminated entries is cheap. - let timer = std::time::Instant::now(); - let deadline_ns: u64 = timeout_ms * 1_000_000; - loop { - live_workers::MUTEX.lock(); - // MUTEX held while walking the intrusive list; HEAD load is safe. - let mut it = live_workers::HEAD.load(); - while let Some(nn) = NonNull::new(it) { - // Worker valid while registered (removed only in shutdown()); - // MUTEX held — `ParentRef` invariant (pointee outlives borrow) holds. - let w = bun_ptr::ParentRef::from(nn); - // live_workers::MUTEX held; list links written only under it. - it = w.live_next.get(); - if w.requested_terminate.swap(true, Ordering::Release) { - continue; - } - w.vm_lock.lock(); - // vm_lock held; `vm` is published/unpublished under vm_lock. - let vm_ptr = w.vm_ptr(); - if !vm_ptr.is_null() { - // SAFETY: vm_ptr published under vm_lock and non-null here. - // jsc_vm is a valid JSC::VM*; notify_need_termination is - // documented thread-safe (VMTraps). Cast through the real - // opaque `crate::VM` (the `crate::VM` stub is layout-only). - // We deliberately do NOT bind `&VirtualMachine` — the worker - // thread may hold a live mutable view of the VM; raw-pointer - // field/method access keeps any autoref scoped to the access. - unsafe { (*(*vm_ptr).jsc_vm.cast_const()).notify_need_termination() }; - // SAFETY: event_loop() returns the live `*mut EventLoop` self-ptr. - unsafe { (*(*vm_ptr).event_loop()).wakeup() }; - } - w.vm_lock.unlock(); - } - live_workers::MUTEX.unlock(); - - let n = live_workers::OUTSTANDING.load(Ordering::Acquire); - if n == 0 { - return; - } - let elapsed = u64::try_from(timer.elapsed().as_nanos()).unwrap_or(u64::MAX); - if elapsed >= deadline_ns { - log!("terminateAllAndWait: timed out with {} outstanding", n); - return; - } - let _ = Futex::wait(&live_workers::OUTSTANDING, n, Some(deadline_ns - elapsed)); +/// Node's `stop_sub_worker_contexts()`: the calling thread is exiting and its +/// stop phase has already asked every child to terminate. Join each one and do +/// the parent-side release the child's `workerGlobalScopeDestroyed` task would +/// have done (that task can no longer run: this context refuses new tasks). +/// Children created by a child are handled by that child's own `shutdown()` +/// before it can be joined, so this is transitively complete. +pub fn join_child_workers(parent: &mut VirtualMachine) { + // `child_workers` is only touched on this (the parent) thread: `create()` + // pushes, `release_parent_poll_ref()` removes, and this takes the rest. + let children = core::mem::take(&mut parent.child_workers); + for child in children { + // SAFETY: registered children are live until the parent releases them + // (the proxy's ref); this is that release. + let messaging_proxy = unsafe { (*child).messaging_proxy }; + WebWorker__parentContextWillDestroy(messaging_proxy); } } +/// The messaging proxy of the worker running on `vm`'s thread, or null on the +/// main thread. Used by the worker-side script bindings (parentPort.postMessage, +/// workerData, ...). #[unsafe(no_mangle)] -extern "C" fn WebWorker__getParentWorker(vm: &VirtualMachine) -> *mut c_void { +extern "C" fn WebWorker__getMessagingProxy(vm: &VirtualMachine) -> *mut c_void { vm.worker_ref() - .map(|w| w.cpp_worker) + .map(|w| w.messaging_proxy) .unwrap_or(core::ptr::null_mut()) } +impl Drop for WebWorker { + fn drop(&mut self) { + log!("[{}] destroy", self.execution_context_id); + debug_assert!( + self.join_handle.with_mut(|h| h.is_none()), + "worker thread was never joined" + ); + } +} + impl WebWorker { pub(crate) fn has_requested_terminate(&self) -> bool { self.requested_terminate.load(Ordering::Acquire) @@ -410,7 +197,7 @@ impl WebWorker { /// Raw read of the `vm` cell. Worker-thread-only callers (which are also /// the writers) may call this without `vm_lock`; cross-thread callers - /// (`notify_need_termination`, `terminate_all_and_wait`) must hold + /// (`request_termination`, an exiting ancestor) must hold /// `vm_lock`. The cell itself is `Cell<*mut _>` so the read is a safe /// `Copy` load; synchronization (where required) is the caller's /// responsibility per the doc above. @@ -466,13 +253,13 @@ impl WebWorker { // Construction (parent thread) // ========================================================================= - /// Allocate the struct, take a keep-alive on the parent event loop, and - /// spawn the worker thread. On any failure returns null with `error_message` - /// set and nothing to clean up (no keep-alive held, no allocation - /// outstanding). + /// Allocate the thread object (one ref, owned by the calling proxy), take a + /// keep-alive on the parent event loop, register as a child of the parent VM, + /// and spawn the thread. On any failure returns null with `error_message` + /// set and nothing to clean up. #[unsafe(export_name = "WebWorker__create")] pub(crate) unsafe extern "C" fn create( - cpp_worker: *mut c_void, + proxy: *mut c_void, parent: *mut VirtualMachine, name_str: BunString, specifier_str: BunString, @@ -542,7 +329,7 @@ impl WebWorker { let store_fd = unsafe { (*parent).transpiler.resolver.store_fd }; let worker = bun_core::heap::into_raw(Box::new(WebWorker { - cpp_worker, + messaging_proxy: proxy, // `parent` is the calling thread's live VM; non-null by FFI contract. parent: bun_ptr::BackRef::from(NonNull::new(parent).expect("parent VM")), execution_context_id: this_context_id, @@ -561,16 +348,17 @@ impl WebWorker { } else { name_str.to_owned_slice_z() }, - live_next: Cell::new(core::ptr::null_mut()), - live_prev: Cell::new(core::ptr::null_mut()), + ref_count: bun_ptr::ThreadSafeRefCount::init(), requested_terminate: AtomicBool::new(false), vm: Cell::new(core::ptr::null_mut()), vm_lock: Mutex::new(), parent_poll_ref: JsCell::new(KeepAlive::init()), + join_handle: JsCell::new(None), status: Cell::new(Status::Start), arena: JsCell::new(None), worker_env_loader: Cell::new(core::ptr::null_mut()), exit_called: AtomicBool::new(false), + terminated_by_parent: AtomicBool::new(false), })); // `worker` is non-null (just heap-allocated). Wrap once for the safe // shared reborrows below; the raw `worker` is still used for @@ -578,136 +366,130 @@ impl WebWorker { let worker_ref = bun_ptr::ParentRef::from(NonNull::new(worker).expect("heap::into_raw is non-null")); - // Keep the parent's event loop alive until the close task releases this. - // If the user passed `{ ref: false }` we skip — they've opted out of the - // worker keeping the process alive. Exception: a nested worker (parent is - // itself a worker, not joined on exit) must hold the parent-loop keepalive - // regardless, because the child holds a non-owning `BackRef` to the parent VM. - // SAFETY: `parent` is live (see above); borrow scoped to the call. - if !default_unref || unsafe { (*parent).worker_ref().is_some() } { - // `worker` is a fresh heap allocation; not yet shared. - // `bun_io::js_vm_ctx()` resolves to this (parent) thread's loop. + // Keep the parent's event loop alive until the parent releases this + // thread, unless the user opted out with `{ ref: false }`. + if !default_unref { + // `bun_io::js_vm_ctx()` is this (the parent) thread's loop. worker_ref.with_parent_poll_ref(|p| p.ref_(bun_io::js_vm_ctx())); } - // Register BEFORE spawning so terminateAllAndWait() can never miss a - // worker whose thread is already running. - live_workers::register(worker); - - // `std::thread` is permitted (only `std::{fs,net,process}` are banned); - // bun_threading has no generic spawn helper. + // The thread's own ref, taken before it exists so it can never observe zero. + worker_ref.ref_(); struct SendPtr(*mut WebWorker); - // SAFETY: `WebWorker` is heap-allocated and the worker thread is the - // sole writer to its worker-thread-only fields; cross-thread fields are - // atomic/locked. The pointer is moved into the new thread exactly once. + // SAFETY: heap-allocated, refcounted; the new thread holds the ref taken above. unsafe impl Send for SendPtr {} let send = SendPtr(worker); let spawn = std::thread::Builder::new() .stack_size(bun_threading::thread_pool::DEFAULT_THREAD_STACK_SIZE as usize) .spawn(move || { let send = send; - // SAFETY: `send.0` is a valid heap `WebWorker` owned by C++; - // `&WebWorker` (not `&mut`) — see worker-thread `&self` note. + // SAFETY: `send.0` is live (the thread's ref); `&WebWorker`, never `&mut`. unsafe { (*send.0).thread_main() }; + // SAFETY: dropping the thread's ref; nothing below touches `send.0`. + unsafe { WebWorker::deref(send.0) }; }); match spawn { Ok(handle) => { - // Detach: see "Known gap" in the file header. - drop(handle); + worker_ref.join_handle.set(Some(handle)); + // SAFETY: `parent` is the calling thread's VM; parent-thread-only list. + unsafe { (*parent).child_workers.push(worker) }; worker } Err(_) => { - live_workers::unregister(worker); - // `worker` not yet shared (spawn failed); parent thread. worker_ref.with_parent_poll_ref(|p| p.unref(bun_io::js_vm_ctx())); - // SAFETY: `worker` is the heap allocation from `heap::into_raw` - // above; spawn failed so it was never shared with another thread. - unsafe { Self::destroy(worker) }; + // SAFETY: never shared; drop both refs (the thread's and the caller's). + unsafe { + WebWorker::deref(worker); + WebWorker::deref(worker); + } *error_message = BunString::static_(b"Failed to spawn worker thread"); core::ptr::null_mut() } } } - /// Free the struct and its owned strings. Called from - /// `WebCore::Worker::~Worker()` (or from `create()` on spawn failure). The - /// allocator is mimalloc (thread-safe), so the caller's thread doesn't - /// matter. - #[unsafe(export_name = "WebWorker__destroy")] - pub(crate) unsafe extern "C" fn destroy(this: *mut WebWorker) { - // SAFETY: this was heap-allocated in create(); C++ owns it and calls - // destroy exactly once. - let this = unsafe { bun_core::heap::take(this) }; - log!("[{}] destroy", this.execution_context_id); - // unresolved_specifier / preloads / name freed by Drop. - drop(this); + fn ref_(&self) { + // SAFETY: `self` is live; the count is atomic. + unsafe { bun_ptr::ThreadSafeRefCount::::ref_(core::ptr::from_ref(self).cast_mut()) }; + } + + /// Drop one ref; the last one frees the allocation (`Drop` below). Any thread. + /// + /// # Safety + /// `this` came from `create()` and the caller owns one ref on it. + #[unsafe(export_name = "WebWorker__deref")] + pub(crate) unsafe extern "C" fn deref(this: *mut WebWorker) { + // SAFETY: fn contract. + unsafe { bun_ptr::ThreadSafeRefCount::::deref(this) }; + } + + /// Block until the OS thread has returned. Parent thread; the worker has + /// either reported `workerGlobalScopeDestroyed` or been asked to terminate + /// by an exiting parent. Termination interrupts script, not a native call + /// the worker is blocked in, so this waits as long as that call does (as + /// Node's JoinThread does). + #[unsafe(export_name = "WebWorker__join")] + pub(crate) extern "C" fn join(this: *mut WebWorker) { + let this = bun_ptr::ParentRef::from(NonNull::new(this).expect("WebWorker FFI ptr")); + if let Some(handle) = this.join_handle.with_mut(Option::take) { + log!("[{}] join", this.execution_context_id); + // A panic on the worker thread has already been reported by the panic + // hook; the join result carries nothing further. + let _ = handle.join(); + } } // ========================================================================= // Parent-thread API (called from C++ via JS) // ========================================================================= - /// worker.ref()/.unref() from JS. The struct is guaranteed alive: it's - /// freed by `~Worker`, which can't run while JSWorker (the caller) holds - /// its `Ref`. `Worker::setKeepAlive()` gates out calls after - /// terminate() or the close task, so this can unconditionally toggle. - /// - /// Takes `*mut` (not `&mut`) because the worker thread concurrently - /// dereferences this struct; materialising `&mut WebWorker` here would be - /// aliased-&mut UB. + /// worker.ref()/.unref(). Parent thread; the proxy holds a ref on `this` + /// and gates out calls once the keep-alive has been released. #[unsafe(export_name = "WebWorker__setRef")] pub(crate) extern "C" fn set_ref(this: *mut WebWorker, value: bool) { - // `this` is a valid heap allocation owned by C++ `WebCore::Worker` - // (alive while JSWorker holds its Ref) — `ParentRef` invariant holds. - // `bun_io::js_vm_ctx()` resolves to this (parent) thread's loop, which - // IS `this.parent`'s loop. let this = bun_ptr::ParentRef::from(NonNull::new(this).expect("WebWorker FFI ptr")); - // A nested worker (parent is itself a worker) must keep the parent-loop - // keepalive even on `.unref()`: the child holds a non-owning `BackRef` to - // the parent VM and worker parents aren't joined on exit. - let parent_is_worker = this.parent.get().worker_ref().is_some(); this.with_parent_poll_ref(|poll| { if value { poll.ref_(bun_io::js_vm_ctx()); - } else if !parent_is_worker { + } else { poll.unref(bun_io::js_vm_ctx()); } }); } - /// worker.terminate() from JS. Sets `requested_terminate`, interrupts - /// running JS in the worker (TerminationException at the next safepoint), - /// and wakes the worker loop so it observes the flag. `parent_poll_ref` - /// stays held until the close task runs so that `await worker.terminate()` - /// keeps the parent alive until 'close' fires. - /// - /// Takes `*mut` (not `&mut`) because the worker thread concurrently - /// dereferences this struct (polling `requested_terminate`, holding - /// `vm_lock`, reading `vm`); materialising `&mut WebWorker` on the parent - /// thread while the worker holds any reference is aliased-&mut UB. - #[unsafe(export_name = "WebWorker__notifyNeedTermination")] - pub(crate) extern "C" fn notify_need_termination(this: *mut WebWorker) { - // `this` is a valid heap allocation owned by C++ `WebCore::Worker` - // (alive while JSWorker holds its Ref) — `ParentRef` invariant holds. - // Only atomic / lock-guarded fields are touched cross-thread; never - // `&mut WebWorker`. + /// Ask the thread to stop: set `requested_terminate`, raise a + /// TerminationException in its VM at the next safepoint, wake its loop. + /// Any thread that holds a ref (the proxy) may call this. + #[unsafe(export_name = "WebWorker__requestTermination")] + pub(crate) extern "C" fn request_termination(this: *mut WebWorker) { let this = bun_ptr::ParentRef::from(NonNull::new(this).expect("WebWorker FFI ptr")); + // vm_lock serialises against shutdown() nulling `vm` and freeing the + // arena it lives in — and is taken *before* the flag is published: a + // worker that breaks out of its loop because it saw the flag then blocks + // in shutdown() until `terminated_by_parent` and the gate are set here, + // instead of racing past with neither. + this.vm_lock.lock(); if this.set_requested_terminate() { + this.vm_lock.unlock(); return; } - log!("[{}] notifyNeedTermination", this.execution_context_id); - - // vm_lock serialises against shutdown() nulling `vm` and freeing the - // arena it lives in. - this.vm_lock.lock(); + log!("[{}] requestTermination", this.execution_context_id); // vm_lock held; `vm` is published/unpublished under vm_lock. let vm_ptr = this.vm_ptr(); if !vm_ptr.is_null() { + // Node: being stopped only counts (exit code 1) once the environment + // exists and before the thread starts tearing it down on its own. + this.terminated_by_parent.store(true, Ordering::Relaxed); + // From now on the worker's native code enters no script and settles + // no promises (Node's `ExitEnv` → `is_stopping`), even before its + // thread notices: whatever completes on its loop meanwhile bails. + // SAFETY: vm_ptr published under vm_lock; the handle is any-thread. + unsafe { (*vm_ptr).handle().stop() }; // SAFETY: vm_ptr published under vm_lock and non-null here. // jsc_vm is a valid JSC::VM*; notify_need_termination is // documented thread-safe (VMTraps). Cast through the real opaque // `crate::VM` (the `crate::VM` stub is layout-only). No - // `&VirtualMachine` binding — see `terminate_all_and_wait`. + // `&VirtualMachine` binding (raw field reads only, off-thread). unsafe { (*(*vm_ptr).jsc_vm.cast_const()).notify_need_termination() }; // SAFETY: event_loop() returns the live `*mut EventLoop` self-ptr. unsafe { (*(*vm_ptr).event_loop()).wakeup() }; @@ -715,23 +497,19 @@ impl WebWorker { this.vm_lock.unlock(); } - /// Release the keep-alive on the parent's event loop. Called on the parent - /// thread from the close task posted by `dispatchExit`. - /// - /// Takes `*mut` for consistency with the other parent-thread FFI exports - /// (the worker thread has exited by the time this runs, so `&mut` would be - /// sound here, but matching signatures avoids surprises). + /// The parent is releasing this thread: drop the keep-alive on the parent's + /// loop and forget it as a child. Parent thread. #[unsafe(export_name = "WebWorker__releaseParentPollRef")] pub(crate) extern "C" fn release_parent_poll_ref(this: *mut WebWorker) { - // `this` is a valid heap allocation owned by C++ — `ParentRef` invariant - // holds; parent-thread only. - let this = bun_ptr::ParentRef::from(NonNull::new(this).expect("WebWorker FFI ptr")); - this.with_parent_poll_ref(|p| p.unref(bun_io::js_vm_ctx())); + let this_ref = bun_ptr::ParentRef::from(NonNull::new(this).expect("WebWorker FFI ptr")); + this_ref.with_parent_poll_ref(|p| p.unref(bun_io::js_vm_ctx())); + // SAFETY: parent thread; `parent` outlives its children (it joins them). + let children = unsafe { &mut (*NonNull::from(this_ref.parent).as_ptr()).child_workers }; + if let Some(i) = children.iter().position(|&c| core::ptr::eq(c, this)) { + children.swap_remove(i); + } } - /// Non-owning back-reference to the parent VM. See field doc for validity - /// (`parent_poll_ref` keeps the parent loop alive until the close task - /// runs). #[inline] pub(crate) fn parent_vm(&self) -> bun_ptr::BackRef { self.parent @@ -742,13 +520,11 @@ impl WebWorker { self.execution_context_id } - /// The owning C++ `WebCore::Worker`. Never null; this struct is freed by - /// `~Worker`, so the pointer cannot dangle. Passed as `worker_ptr` to - /// `Zig__GlobalObject__create` so the ZigGlobalObject is born with its - /// WorkerGlobalScope wired. + /// The C++ `WorkerMessagingProxy`, handed to `Zig__GlobalObject__create` so + /// the worker's global is born knowing its options (env, argv, workerData). #[inline] - pub(crate) fn cpp_worker(&self) -> *mut c_void { - self.cpp_worker + pub(crate) fn messaging_proxy(&self) -> *mut c_void { + self.messaging_proxy } #[inline] @@ -761,8 +537,8 @@ impl WebWorker { // ========================================================================= // Worker-thread call chain takes `&self` (NOT `&mut self`): the parent / - // main thread may concurrently hold `&WebWorker` (`notify_need_termination`, - // `terminate_all_and_wait`), so materialising `&mut WebWorker` here would + // main thread may concurrently hold `&WebWorker` (`request_termination`, + // an exiting ancestor), so materialising `&mut WebWorker` here would // be aliased-&mut UB. Worker-thread-only mutable fields are wrapped in // `Cell` / `UnsafeCell` instead. fn thread_main(&self) { @@ -776,8 +552,8 @@ impl WebWorker { )); } - // Terminated before we even started — skip straight to shutdown so the - // parent still gets a close event and the thread ref is dropped. + // Terminated before we even started — straight to shutdown so the + // parent still gets its close event. if self.has_requested_terminate() { self.shutdown(); return; @@ -793,16 +569,7 @@ impl WebWorker { } }; - // `start_vm()` may have observed `requested_terminate` and - // run `shutdown()` itself (which now returns instead of `noreturn`). - // In that case it returns `Ok(null)` and there is nothing left to do — - // fall out of `thread_main` so the thread exits cleanly. We must NOT - // read `self.vm_ptr()` here to make that decision: `shutdown()` has - // already posted `dispatchExit`, after which `self` may be freed by - // `~Worker` on the parent thread (the close task drops the - // thread-held ref; if the JS wrapper has been GC'd, `WebWorker__destroy` - // races this read — sporadic UAF in worker_threads tests that - // `terminate()` immediately after `new Worker()`). + // `start_vm()` observed `requested_terminate` and already ran `shutdown()`. if vm_ptr.is_null() { return; } @@ -812,31 +579,17 @@ impl WebWorker { // the safe thread-local accessor returns the same allocation. debug_assert!(core::ptr::eq(vm_ptr, VirtualMachine::get_mut_ptr())); let global = VirtualMachine::get().global(); - // We cannot use `pthread_exit` (its forced unwind aborts at - // the first `extern "C"` boundary — see the `shutdown` note), so - // `spin()` returns. The API lock - // is simply abandoned along with the destroyed VM: take the lock via - // raw FFI (NOT the `Lock<'_>` RAII guard) and never release it. - // `WebWorker__teardownJSCVM` correspondingly `deref`s once, since - // this path takes no extra `RefPtr` - // — see the matching note in `Worker.cpp`. - // - // We deliberately do NOT use `get_api_lock()` + `mem::forget(guard)`: - // the guard holds `vm: &VM`, which would dangle after `spin()` → - // `shutdown()` destroys the `JSC::VM`, and a live `&T` to freed - // memory is UB under Rust's validity rules even when never - // dereferenced. The raw FFI call has no such reference to leak. + // Take the API lock for the thread's whole life and abandon it with the + // VM (`shutdown()` destroys the `JSC::VM`; there is nothing to unlock). + // Raw FFI rather than the RAII guard, whose `&VM` would dangle. JSC__VM__getAPILock(global.vm()); self.spin(); } /// Phase 1: build the worker's arena + VirtualMachine and publish `vm`. /// - /// Returns the published VM pointer so `thread_main` need not re-read it - /// from `self` — `Ok(null)` means the early-terminate checkpoint already - /// ran `shutdown()` (after which `self` may be freed by `~Worker` on the - /// parent thread; touching `self` past that point is the UAF this return - /// shape exists to prevent). + /// Returns the published VM pointer; `Ok(null)` means the early-terminate + /// checkpoint already ran `shutdown()`. fn start_vm(&self) -> Result<*mut VirtualMachine, crate::CrateError> { debug_assert!(self.status.get() == Status::Start); debug_assert!(self.vm_ptr().is_null()); @@ -907,15 +660,12 @@ impl WebWorker { self.worker_env_loader.set(loader_ptr); // Checkpoint before the expensive part: initWorker builds a full JSC - // VM. If terminateAllAndWait() fired while we were cloning the env + // VM. If a parent's request_termination() fired while we were cloning the env // above, bail now rather than spending ~50–100ms (release) creating a // VM that will immediately tear down. if self.has_requested_terminate() { drop(temp_proxy_slots); self.shutdown(); - // `self` may be freed past this point (shutdown posted dispatchExit - // → parent close task may drop the last Worker ref). Do NOT touch - // `self`; signal "already shut down" via the null return. return Ok(core::ptr::null_mut()); } @@ -932,7 +682,7 @@ impl WebWorker { // Pre-publish init: the VM is not yet visible to the parent thread, // so a scoped `&mut VirtualMachine` is safe here. The borrow MUST // end before the publish below — once `self.vm` is published under - // `vm_lock`, `notify_need_termination` / `terminate_all_and_wait` + // `vm_lock`, `request_termination` // may concurrently dereference the same pointer on another thread, // and a still-live `&mut VirtualMachine` would be aliased-&mut UB. { @@ -955,7 +705,7 @@ impl WebWorker { } // Publish `vm` now (rather than at the end of startVM) so that: - // - a concurrent notifyNeedTermination()/terminateAllAndWait() can + // - a concurrent request_termination() (parent, or an exiting ancestor) can // wake us once JS starts running, and // - early returns below reach spin()/shutdown() with this.vm set, // so teardownJSCVM/vm.deinit() run and the just-built JSC::VM @@ -972,7 +722,7 @@ impl WebWorker { // Post-publish: do NOT re-form `&mut VirtualMachine`. Field/method // access goes through the raw `*mut` so any autoref is scoped to the // single expression. The parent-thread readers likewise never bind - // `&VirtualMachine` (see `terminate_all_and_wait`). + // `&VirtualMachine` (see `request_termination`). // SAFETY: `vm` is a valid heap-allocated VM ptr (checked above). unsafe { let b = &mut (*vm).transpiler; @@ -1018,7 +768,7 @@ impl WebWorker { // vm published in start_vm; non-null past this point. Do NOT bind a // long-lived `&mut VirtualMachine`: while the event loop runs, the // parent / main thread may dereference the same pointer under - // `vm_lock` (`notify_need_termination`, `terminate_all_and_wait`). + // `vm_lock` (`request_termination`, an exiting ancestor). // Those cross-thread paths only form raw-ptr field reads (never // `&mut VirtualMachine`), so holding `&VirtualMachine` here is sound; // mutation goes through `vm.as_mut()` which forms a fresh short-lived @@ -1105,13 +855,23 @@ impl WebWorker { // Fire (and clear) the entryEvaluated hook on EVERY post-evaluation path // so buffered postMessageToThread deliveries drain and the sender's - // Atomics.waitAsync settles. dispatchOnline re-calls it as a no-op. + // Atomics.waitAsync settles. WebWorker__entrySettled re-calls it as a no-op. WebWorker__entrySettled(vm.global()); - // SAFETY: `promise` is a live JSC heap cell. - unsafe { - let status = (*promise).status(); - if status == jsc::js_promise::Status::Rejected { + // The entry's evaluation outcome is checked once now and then after every + // loop turn: a rejection (immediate, or a top-level await rejecting + // later) is the entry's uncaught error at that moment — the worker stops + // unless a handler took it — and is reported exactly once. The loader + // marks this promise handled, so nothing else would report it. + let mut entry_rejection_seen = false; + let mut observe_entry = |vm: &VirtualMachine| -> EntryOutcome { + // SAFETY: `promise` is a live JSC heap cell, rooted below for the loop's duration. + unsafe { + if entry_rejection_seen || (*promise).status() != jsc::js_promise::Status::Rejected + { + return EntryOutcome::Continue; + } + entry_rejection_seen = true; // Same rule as the main thread (run_command): a CJS worker // entry's top-level throw is an uncaughtException; only an // ESM entry rejection reports origin "unhandledRejection". @@ -1121,36 +881,34 @@ impl WebWorker { (*promise).result(vm.jsc_vm()), is_rejection, ); - if !handled { - // exit_code is already 1 from uncaught_exception; re-setting it here - // would clobber a process.on('exit') change to process.exitCode. - return self.shutdown(); - } - } else if status == jsc::js_promise::Status::Pending { - // Unsettled top-level await (loop drained, entry promise still - // pending): node exits the worker with code 13, but only if the - // user hasn't set a nonzero process.exitCode. - if vm.exit_handler.exit_code == 0 { - vm.as_mut().exit_handler.exit_code = 13; + if handled { + EntryOutcome::Continue + } else { + EntryOutcome::Stop } - self.flush_logs(vm); - return self.shutdown(); - } else { - let _ = (*promise).result(vm.jsc_vm()); } + }; + if let EntryOutcome::Stop = observe_entry(vm) { + // exit_code is already 1 from uncaught_exception; re-setting it here + // would clobber a process.on('exit') change to process.exitCode. + return self.shutdown(); } + // A still-pending entry promise is an unsettled top-level await: as in + // Node the worker counts as started once its module graph is executing, + // and the await continues in the normal event loop below — messages, + // timers and I/O keep flowing meanwhile. Rooted for the loop's duration. + let entry_promise = crate::Strong::create( + JSValue::from_cell(promise.cast::()), + vm.global(), + ); self.flush_logs(vm); log!("[{}] event loop start", self.execution_context_id); - // dispatchOnline fires the parent-side 'open' event and flips the C++ - // state to Running (which routes postMessage directly instead of - // queuing). It is placed after the entry point has loaded so the parent - // observes 'online' only once the worker's top-level code has completed; - // moving it earlier would change that observable ordering. - // `cpp_worker` is the opaque C++-owned handle round-tripped via `safe fn`; - // `vm.global()` yields the live `&JSGlobalObject` published in start_vm. - WebWorker__dispatchOnline(self.cpp_worker, vm.global()); - WebWorker__fireEarlyMessages(self.cpp_worker, vm.global()); + // Pending -> Running: 'online' is posted to the parent and messages/tasks + // that arrived while the entry point was loading are delivered. After the + // entry point on purpose, so the parent observes 'online' only once the + // worker's top-level code has run (up to its first top-level await). + WebWorker__workerGlobalScopeStarted(self.messaging_proxy, vm.global()); self.set_status(Status::Running); // don't run the GC if we don't actually need to @@ -1162,18 +920,26 @@ impl WebWorker { } // Always do a first tick so we call CppTask without delay after - // dispatchOnline. + // workerGlobalScopeStarted. vm.as_mut().tick(); + let mut stopped_by_entry = matches!(observe_entry(vm), EntryOutcome::Stop); - while vm.is_event_loop_alive() { + while !stopped_by_entry && vm.is_event_loop_alive() { vm.as_mut().tick(); if self.has_requested_terminate() { break; } + if let EntryOutcome::Stop = observe_entry(vm) { + stopped_by_entry = true; + break; + } vm.as_mut().auto_tick_active(); if self.has_requested_terminate() { break; } + if let EntryOutcome::Stop = observe_entry(vm) { + stopped_by_entry = true; + } } log!( @@ -1181,51 +947,44 @@ impl WebWorker { self.execution_context_id, if self.has_requested_terminate() { "(terminated)" + } else if stopped_by_entry { + "(entry rejected)" } else { "(event loop dead)" } ); - // Only emit 'beforeExit' on a natural drain, not on terminate(). - if !self.has_requested_terminate() { + if !self.has_requested_terminate() && !stopped_by_entry { + // Only emit 'beforeExit' on a natural drain, not on terminate(). // TODO: is this able to allow the event loop to continue? vm.as_mut().on_before_exit(); + // Drained with the entry still pending: an unsettled top-level await, + // Node's exit 13 (unless the user chose a nonzero exit code). + // SAFETY: rooted by `entry_promise`. + if unsafe { (*promise).status() } == jsc::js_promise::Status::Pending + && vm.exit_handler.exit_code == 0 + { + vm.as_mut().exit_handler.exit_code = 13; + } } + drop(entry_promise); self.flush_logs(vm); self.shutdown(); } - /// Phase 3: run exit handlers, tear down the JSC VM, post the close - /// event, free the arena, exit the thread. - /// - /// Ordering constraints (each step is a barrier for the next): - /// 1. `vm = null` under lock — a racing notifyNeedTermination() now sees - /// null and skips wakeup() instead of touching - /// memory freed in step 5. - /// 2. `vm.onExit()` — user 'exit' handlers run; needs the JSC VM. - /// 3. `teardownJSCVM()` — collectNow + vm.deref (single — the - /// API-lock path takes no extra - /// `RefPtr`, see the `thread_main` - /// note); can re-enter via - /// finalizers, so must precede step 5. - /// 4. `dispatchExit()` — posts close task → parent releases - /// parent_poll_ref + thread-held Worker ref. - /// After this `this` may be freed at any time. - /// 5. free loop/arena/pools — no `this.*` dereferences below step 4. - /// - /// Does NOT free `this` — see ownership rule in the file header. - /// - /// Returns `()` and lets the thread fall out of the spawn - /// closure — see the note at the bottom of this fn. + /// Phase 3: unpublish `vm` under `vm_lock` (a racing `requestTermination` + /// now sees null), run the user 'exit' handlers, then the shared + /// [`VirtualMachine::teardown`] (stop → forbid script → ~VM → loops → + /// destroy), free the thread's remaining state, and last of all report + /// `workerGlobalScopeDestroyed` — the parent joins this thread from that + /// task, so nothing after it may touch the parent. fn shutdown(&self) { jsc::mark_binding(); self.set_status(Status::Terminated); bun_analytics::features::workers_terminated.fetch_add(1, Ordering::Relaxed); log!("[{}] shutdown", self.execution_context_id); - // Snapshot everything we'll need after `this` may be freed (step 4). - let cpp_worker = self.cpp_worker; // worker-thread only field; no other thread reads `arena`. let mut arena = self.arena.replace(None); let env_loader = self.worker_env_loader.replace(core::ptr::null_mut()); @@ -1235,141 +994,28 @@ impl WebWorker { // vm_lock held; this is the unpublish point. let vm_ptr = self.vm.replace(core::ptr::null_mut()); self.vm_lock.unlock(); - let mut loop_: Option<*mut bun_uws::Loop> = None; - if !vm_ptr.is_null() { - // SAFETY: vm_ptr was published under vm_lock; sole owner now. - loop_ = Some(unsafe { &*vm_ptr }.uws_loop()); - } // ---- 2. User exit handlers ----------------------------------------- let mut exit_code: i32 = 0; - let mut global_object: Option<*const JSGlobalObject> = None; if !vm_ptr.is_null() { // SAFETY: vm_ptr valid; unpublished above under vm_lock, so no // other thread can dereference it now — `&mut` is exclusive. let vm = unsafe { &mut *vm_ptr }; - // terminate() set the JSC termination flag to interrupt running JS; - // clear it so process.on('exit') handlers can run. teardownJSCVM - // re-sets it for the JSC VM teardown. - vm.jsc_vm().clear_has_termination_request(); vm.is_shutting_down = true; vm.on_exit(); - if let Some(hooks) = runtime_hooks() { - (hooks.cron_clear_all_teardown)(vm); - // Drain `TimeoutObject`s from this worker's timer heap before - // `close_all_socket_groups` / `WebWorker__teardownJSCVM` so - // their heap nodes are unlinked while `runtime_state` and the - // JSC heap are both still alive. - // SAFETY: `vm_ptr` was unpublished under `vm_lock` above, so - // this thread is the sole owner; `runtime_state` for this - // worker thread is still installed (torn down in `destroy()`). - unsafe { (hooks.cancel_all_timers)(vm_ptr) }; - } - // Same reason: the GC timers are heap nodes too. - vm.gc_controller.deinit(); - // Embedded socket groups must drain while JSC is still alive — - // closeAll() fires on_close → JS callbacks. RareData.deinit() runs - // after teardownJSCVM and only deinit()s (asserts empty in debug). - if let Some(rare) = vm.rare_data.as_deref_mut() { - // reshaped for borrowck — `close_all_socket_groups` - // wants `&VirtualMachine` while `rare` is `&mut` borrowed from - // `vm`. Re-derive `vm` through the raw ptr (sole owner). - - // SAFETY: `vm_ptr` was unpublished under `vm_lock` above, so this - // thread is the sole owner; the JSC VM is still alive (teardown - // is step 3 below). - rare.close_all_socket_groups(unsafe { &*vm_ptr }); - } - // Destroy the per-VM c-ares channel now: `ares_destroy()` fires - // every pending query callback with `ARES_EDESTRUCTION` and then - // the socket-state callback for each fd it closes, both of which - // dereference state (`JSGlobalObject`, `RareData.file_polls`, - // `runtime_state().timer`) that step 3/5 below free. Deferring it - // to `destroy()`'s `deinit_runtime_state` is a UAF. Must FOLLOW - // `close_all_socket_groups`: its on_close JS can call - // `dns.resolve*()`, and `Resolver::get_channel()` lazily re-inits - // on `channel == None` — running this earlier lets a re-created - // channel survive to `GlobalData::drop` (the original UAF). - if let Some(hooks) = runtime_hooks() { - (hooks.close_dns_for_terminate)(); - } - // 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 - // it again (redundantly) after the drain; without this earlier - // call a parent-side MessagePort ack (worker stdio backpressure) - // posted in the gap would sit in concurrent_tasks past the raw VM - // dealloc and leak under LSan. - ScriptExecutionContext__markTerminating(vm.global()); - // Same for JSCTaskScheduler: a cross-thread Atomics.notify that - // races this shutdown either enqueues (and is caught by the drain) - // or observes m_isShuttingDown under m_lock and drops. Idempotent; - // teardownJSCVM sets it again. - Bun__JSCTaskScheduler__markShuttingDown(vm.global()); - // Reclaim queued CppTasks while JSC is still live (after teardownJSCVM the worker VM is - // dealloc'd-without-Drop so anything still in self.tasks leaks). Work-pool fs completions - // post without a shutdown check; wait for in-flight ones so the drain below sees every - // post. - vm.event_loop_mut().wait_for_concurrent_posters(); - vm.event_loop_mut().release_queued_tasks_for_shutdown(); - if let Some(rare) = vm.rare_data.as_deref_mut() { - rare.release_js_handles(); - } exit_code = i32::from(vm.exit_handler.exit_code); - global_object = Some(vm.global); - } + log!( + "[{}] shutdown: exit handlers done", + self.execution_context_id + ); - // ---- 3. JSC VM teardown -------------------------------------------- - if let Some(global) = global_object { - // `JSGlobalObject` is an opaque ZST handle; `opaque_ref` is the - // centralised non-null deref proof (JSC VM still alive here). - WebWorker__teardownJSCVM(JSGlobalObject::opaque_ref(global)); - } + // ---- 3–5. Stop, forbid script, ~VM, loops, destroy --------------- + // SAFETY: unpublished under `vm_lock`; this thread is the sole owner. + unsafe { VirtualMachine::teardown(vm_ptr, crate::virtual_machine::Teardown::Worker) }; - // The finalizers JSC just ran close the sockets that `close_all_socket_groups` leaves - // alone (a Listener owns its listen socket and closes it in `finalize`). `us_socket_close` - // only queues onto `loop->data.closed_head`; step 5's `on_thread_exit()` frees the loop - // out from under whatever is still queued, so drain it now, while the loop is alive. - if !vm_ptr.is_null() { - // SAFETY: `vm_ptr` was unpublished under `vm_lock`; sole owner, `destroy()` is below. - unsafe { (*vm_ptr).uws_loop_mut().drain_closed_sockets() }; - } - - // JSC is down; no more resolver/module-loader access past this point. - // Unlink so the main thread's terminateAllAndWait() sweep skips us; - // the OUTSTANDING decrement is deferred until after dispatchExit so - // terminateAllAndWait() doesn't return before the close task is - // posted (global_exit drains the parent's concurrent queue right - // after). Unlink touches `self` and so must precede dispatchExit; - // mark_exited() does not, so the post-dispatchExit "this may be - // freed" window is fine. - live_workers::unlink(self); - - // ---- 4. Post close task to parent ---------------------------------- - // `cpp_worker` is the opaque C++-owned handle (snapshot taken above). - WebWorker__dispatchExit(cpp_worker, exit_code); - // `this` may be freed past this point. - live_workers::mark_exited(); - - // ---- 5. Free worker-thread resources ------------------------------- - if let Some(loop_) = loop_ { - // SAFETY: loop owned by this thread's VM; no concurrent access. - unsafe { (*loop_).internal_loop_data.jsc_vm = core::ptr::null_mut() }; - } - #[cfg(windows)] - { - // Per-thread libuv loop teardown; closes any handles still open on - // this worker's loop and drops the thread-local pointer. - bun_sys::windows::libuv::Loop::shutdown(); - } - if !vm_ptr.is_null() { - // SAFETY: vm_ptr valid; sole owner. - unsafe { (*vm_ptr).destroy() }; - // Reclaim the boxes allocated on the global - // heap in `VirtualMachine::init` — `destroy()` only deinits the - // fields, not the box storage. Worker `init_worker` always passes - // `log: None`, so the log box is VM-owned here. + // `destroy()` deinits the fields; reclaim the storage `init` put on + // the global heap (worker `init_worker` always passes `log: None`, + // so the log box is VM-owned here). // SAFETY: sole owner; nothing past this point dereferences the VM. unsafe { let console = core::mem::replace(&mut (*vm_ptr).console, core::ptr::null_mut()); @@ -1389,42 +1035,51 @@ impl WebWorker { ); } } + log!( + "[{}] shutdown: VirtualMachine destroyed", + self.execution_context_id + ); // Reclaim the cloned env (`heap::alloc`'d in `start_vm()`; see field doc). if !env_loader.is_null() { // SAFETY: `heap::alloc`'d in `start_vm`; sole owner; the VM is // gone so its raw `transpiler.env` borrow is dead. drop(unsafe { bun_core::heap::take(env_loader) }); } - // Same reason as the uWS loop below: this thread's C++ thread_local destructors are not - // guaranteed to run before the process exits, so free the HPACK scratch buffer that any + // This thread's C++ thread_local destructors are not guaranteed to run + // before the process exits, so free the HPACK scratch buffer that any // http2 session on this thread allocated. Bun__freeSharedHeaderBufferForThreadExit(); - // Free this thread's lazily-created uWS loop and its 512 KiB recv - // buffer. The C++ thread_local `~LoopCleaner` does not fire here: - // we return normally and - // unwinding never crosses the `extern "C"` frame, so the destructor is - // skipped on glibc; under BUN_DESTRUCT_VM_ON_EXIT it would also gate - // on `!bun_is_exiting()`. Everything that registers polls on the loop - // (gc_controller, sockets, timers) has been deinit'd above. - bun_uws::on_thread_exit(); drop(arena.take()); + log!( + "[{}] shutdown: thread state freed", + self.execution_context_id + ); + + // ---- 6. Report to the parent ------------------------------------------ + // The parent joins this thread from that task, so it must be the last + // thing here; the thread then returns normally (never `pthread_exit`: + // its forced unwind would cross `extern "C"` frames and abort). + // A worker stopped by its parent that never called process.exit() did + // not choose `exit_code`; the proxy decides what that reads as per kind. + WebWorker__workerGlobalScopeDestroyed( + self.messaging_proxy, + exit_code, + self.stopped_by_parent(), + ); + } - // We MUST NOT call `pthread_exit` here — - // glibc's `pthread_exit` throws a `__forced_unwind` - // C++ exception to run destructors, and unwinding that across an - // `extern "C"` (`nounwind`) Rust frame on the way out to - // `std::thread`'s entry point makes Rust abort the whole process. - // Instead return normally: `shutdown()` → `spin()` → `thread_main` - // (which `forget`s the API-lock guard) → the `std::thread` spawn - // closure, which then exits the thread cleanly. No `this.*` is - // touched past `dispatchExit` above, so the `this`-may-be-freed - // contract still holds across the unwind-free return path. + /// worker.terminate() from the parent, and the worker did not also exit on + /// its own (process.exit / uncaught error) — Node's "stopped" case: no exit + /// handlers run and the exit code was not the worker's choice. + pub fn stopped_by_parent(&self) -> bool { + self.terminated_by_parent.load(Ordering::Relaxed) + && !self.exit_called.load(Ordering::Relaxed) } /// process.exit() inside the worker. Worker-thread only. /// - /// Takes `&self` (not `&mut self`) because `terminate_all_and_wait` / - /// `notify_need_termination` may concurrently hold `&WebWorker` on another + /// Takes `&self` (not `&mut self`) because `request_termination` / + /// other threads may concurrently hold `&WebWorker` on another /// thread; producing `&mut` here would be aliased-&mut UB. pub fn exit(&self) { self.exit_called.store(true, Ordering::Relaxed); @@ -1439,7 +1094,12 @@ impl WebWorker { // SAFETY: vm_ptr non-null; jsc_vm is a valid JSC::VM*; // notify_need_termination is documented thread-safe (VMTraps). // Cast through the real opaque `crate::VM`. - unsafe { (*(*vm_ptr).jsc_vm.cast_const()).notify_need_termination() }; + unsafe { + // As for a parent's terminate(): nothing more may enter script + // (Node's `Stop(env)` on the worker's own exit). + (*vm_ptr).handle().stop(); + (*(*vm_ptr).jsc_vm.cast_const()).notify_need_termination(); + } } } @@ -1456,10 +1116,14 @@ impl WebWorker { self.status.set(status); } + /// Report the VM log (entry resolution / load errors) to the parent as the + /// worker's 'error' event. Nothing is reported once the worker is being + /// stopped: the parent asked for exactly that, and building the error + /// object would run into the pending termination. fn flush_logs(&self, vm: &VirtualMachine) { jsc::mark_binding(); let vm_log = vm.log_ref().unwrap(); - if vm_log.msgs.is_empty() { + if vm_log.msgs.is_empty() || !vm.script_allowed() { return; } let global = vm.global(); @@ -1471,12 +1135,17 @@ impl WebWorker { let (err, str) = match result { Ok(pair) => pair, Err(JsError::OutOfMemory) => bun_core::out_of_memory(), - Err(JsError::Thrown | JsError::Terminated) => panic!("unhandled exception"), + // A termination request landed while building the error: as above. + Err(JsError::Terminated) => return, + Err(JsError::Thrown) => { + // Building an error from log messages threw: report that instead. + global.report_active_exception_as_unhandled(JsError::Thrown); + return; + } }; let mut str = bun_core::OwnedString::new(str); let dispatch = jsc::host_fn::from_js_host_call_generic(global, || { - // `cpp_worker` is the opaque C++-owned handle; `str` reffed for the call. - WebWorker__dispatchError(global, self.cpp_worker, &mut str, err) + WebWorker__dispatchError(global, self.messaging_proxy, &mut str, err) }); if let Err(e) = dispatch { // `take_exception` on a `JsError` always returns an Exception @@ -1504,6 +1173,14 @@ fn on_unhandled_rejection( // Prevent recursion vm.on_unhandled_rejection = VirtualMachine::on_quiet_unhandled_rejection_handler_capture_value; + // The stop was already requested (terminate(), or the worker's own exit): + // whatever rejects or throws from here on is a consequence of stopping — + // a cancelled lookup, an aborted request — and is not the worker's error + // to report. Node: terminate() wins; no 'error' event. + if !vm.script_allowed() { + return; + } + let mut error_instance = error_instance_or_exception .to_error() .unwrap_or(error_instance_or_exception); @@ -1561,10 +1238,9 @@ fn on_unhandled_rejection( // last-resort error handler and about to arm termination. let mut error_message = bun_core::OwnedString::new(BunString::clone_utf8(&array)); if jsc::host_fn::from_js_host_call_generic(global_object, || { - // `cpp_worker` is the opaque C++-owned handle round-tripped via `safe fn`. WebWorker__dispatchError( global_object, - worker.cpp_worker, + worker.messaging_proxy, &mut error_message, error_instance, ); @@ -1581,13 +1257,14 @@ fn on_unhandled_rejection( let _ = worker.set_requested_terminate(); // Do NOT call `worker.shutdown()` here — // `shutdown()` RETURNS, so calling it here would destroy - // the `JSC::VM`, free the Bun `VirtualMachine` + arena, and post - // `dispatchExit` (after which `worker` itself may be freed), then return - // through `VirtualMachine::uncaught_exception` (which writes + // the `JSC::VM`, free the Bun `VirtualMachine` + arena, and report + // `workerGlobalScopeDestroyed`, then return through + // `VirtualMachine::uncaught_exception` (which writes // `is_handling_uncaught_exception = false` on the freed VM), through live // JSC C++ frames operating on a destroyed `JSC::VM`, and back into // `spin()` which dereferences the freed `*vm` and calls `shutdown()` a - // second time (double `dispatchExit` → double C++ `Worker` deref). + // second time (a second `workerGlobalScopeDestroyed` → double deref of + // the proxy's thread-held reference). // // Instead, arm the JSC termination trap so any further JS halts at the // next safepoint, and let the stack unwind normally back to `spin()`, @@ -1598,7 +1275,10 @@ fn on_unhandled_rejection( // `return self.shutdown()` directly — same observable ordering. // `vm.jsc_vm` is the worker's live `JSC::VM*` (we just used it via // `global_object`); `notify_need_termination` is documented thread-safe - // (VMTraps). + // (VMTraps). The gate closes with it, as for exit()/terminate(): the + // native→JS entries still reached this tick refuse rather than run into + // the pending termination. + vm.handle().stop(); vm.jsc_vm().notify_need_termination(); } @@ -1611,8 +1291,8 @@ fn on_unhandled_rejection( /// `parent` must point at a live `VirtualMachine`. Passed as a raw pointer /// (not `&mut`) because when called from `spin()` the WORKER's VM has already /// been published under `vm_lock`; the parent / main thread may concurrently -/// dereference the same allocation in `notify_need_termination` / -/// `terminate_all_and_wait` (`(*vm_ptr).jsc_vm`, `(*vm_ptr).event_loop()`). +/// dereference the same allocation in `request_termination` +/// (`(*vm_ptr).jsc_vm`, `(*vm_ptr).event_loop()`). /// A live `&mut VirtualMachine` here would be aliased-&mut UB. Per-use /// `(*parent)` derefs keep any autoref scoped to the single expression — the /// same pattern `spin()` uses post-publish. @@ -1693,6 +1373,13 @@ unsafe fn resolve_entry_point_specifier<'s>( } } + // A `data:` URL is the module itself (the loader decodes it); it never names + // a path, so it must not go through path resolution (long ones would fail + // with ENAMETOOLONG there). + if str.starts_with(b"data:") { + return Some(str); + } + // Spec `bun.webcore.ObjectURLRegistry.isBlobURL(str)` — prefix `"blob:"` // AND `len >= specifier_len` (`"blob:".len + UUID.stringLength = 41`). // A short `"blob:foo"` must fall through to the resolver below, not enter diff --git a/src/jsc_macros/lib.rs b/src/jsc_macros/lib.rs index 153ee01f8549..291df899082a 100644 --- a/src/jsc_macros/lib.rs +++ b/src/jsc_macros/lib.rs @@ -986,3 +986,46 @@ fn classify_uws_arg(ty: &syn::Type) -> UwsArg { } UwsArg::PassThrough(ty.clone()) } + +/// `#[derive(JsAffine)]` — the struct/enum may live in a job's JS-side +/// partition (`bun_jsc::job::JsSide`): every field must itself be +/// `JsAffine`, which the expansion checks with one bound per field type, so +/// a field that owns process memory (a `Vec`, a `Box`, a C library handle) is +/// a compile error here rather than a leak-or-UAF decision at teardown. +#[proc_macro_derive(JsAffine)] +pub fn derive_js_affine(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as syn::DeriveInput); + let name = &input.ident; + let (impl_g, ty_g, where_g) = input.generics.split_for_impl(); + let mut field_tys: Vec<&syn::Type> = Vec::new(); + match &input.data { + syn::Data::Struct(s) => field_tys.extend(s.fields.iter().map(|f| &f.ty)), + syn::Data::Enum(e) => { + for v in &e.variants { + field_tys.extend(v.fields.iter().map(|f| &f.ty)); + } + } + syn::Data::Union(u) => { + return syn::Error::new_spanned(u.union_token, "JsAffine: unions are not supported") + .to_compile_error() + .into(); + } + } + let checks = field_tys.iter().map(|ty| { + quote! { __assert_js_affine::<#ty>(); } + }); + // The field checks sit in an associated const on the type itself, so its + // generic parameters are in scope and nothing is left unused. + quote! { + // SAFETY: every field is `JsAffine` (checked below). + unsafe impl #impl_g ::bun_jsc::job::JsAffine for #name #ty_g #where_g {} + #[doc(hidden)] + impl #impl_g #name #ty_g #where_g { + pub const __JS_AFFINE_FIELDS: () = { + const fn __assert_js_affine() {} + #(#checks)* + }; + } + } + .into() +} diff --git a/src/libuv_sys/libuv.rs b/src/libuv_sys/libuv.rs index 4f20442fbd46..658f13a690b0 100644 --- a/src/libuv_sys/libuv.rs +++ b/src/libuv_sys/libuv.rs @@ -22,13 +22,16 @@ use core::{fmt, mem, ptr}; // ────────────────────────────────────────────────────────────────────────── // Debug log scope (`bun.Output.scoped(.uv, .hidden)`). This crate is leaf -// (no `bun_output` dep), so the macro compiles to nothing in release and to -// an `eprintln!` gated by `BUN_DEBUG_uv` in debug. +// (no `bun_output` dep): an `eprintln!` gated by `BUN_DEBUG_uv` in debug, a +// constant-false branch in release — the arguments stay type-checked (and +// count as used) in both, like `bun_core::scoped_log!`. // ────────────────────────────────────────────────────────────────────────── #[doc(hidden)] -#[cfg(debug_assertions)] #[inline] pub fn __uv_log_enabled() -> bool { + if !cfg!(debug_assertions) { + return false; + } // `Output.scoped` reads the env var once at startup; `inc/dec` are on the // per-handle ref/unref hot path, so cache the lookup instead of paying a // GetEnvironmentVariableW syscall + alloc per tick. @@ -39,8 +42,7 @@ pub fn __uv_log_enabled() -> bool { #[macro_export] macro_rules! __uv_log { ($($arg:tt)*) => {{ - #[cfg(debug_assertions)] - if $crate::__uv_log_enabled() { + if ::core::cfg!(debug_assertions) && $crate::__uv_log_enabled() { ::std::eprintln!("[uv] {}", ::std::format_args!($($arg)*)); } }}; @@ -401,6 +403,24 @@ thread_local! { static THREADLOCAL_LOOP: Cell<*mut Loop> = const { Cell::new(ptr::null_mut()) }; } +// ────────────────────────────────────────────────────────────────────────── +// Open stream/process handles on this thread — Bun's HandleWrap list. +// +// Every `uv_pipe_t`, `uv_tty_t` (except the process-static stdin tty) and +// `uv_process_t` this thread initialises is listed here from `init`/`spawn` +// until the `uv_close` for it is issued (`UvHandle::close`, +// `Pipe::close_and_destroy`). Whoever currently drives the handle records +// itself with [`open_handles::set_owner`] — readers/writers do so through +// `Source::set_owner`, IPC / named pipes / Process when they take the handle — +// so a thread teardown can close each handle through its owner's ordinary +// close path (parents observe the close; pending writes finish ECANCELED) +// while the VM is alive, or directly if nothing ever adopted it. Keyed by the +// handle's address, which is stable for its life (boxed / embedded in a boxed +// owner), so ownership moving between objects needs no re-registration. +// ────────────────────────────────────────────────────────────────────────── +#[path = "open_handles.rs"] +pub mod open_handles; + impl Loop { /// Returns this thread's /// libuv loop, lazily `uv_loop_init`ing it on first call. Each thread owns @@ -427,9 +447,41 @@ impl Loop { }) } - /// Closes this - /// thread's libuv loop. Called from `WebWorker::shutdown`. - pub fn shutdown() { + /// Complete every in-flight request on this thread's loop (fs, write, + /// connect): requests cannot be cancelled, and their callbacks expect the + /// VM that issued them — and may start more work (a shell pipeline moving + /// to its next builtin) — so teardown runs this in its stop phase, script + /// forbidden but the VM alive and still accepting (and later awaiting) + /// off-thread work (Node's `CleanupHandles`). `true` if anything ran. + pub fn drain_requests() -> bool { + THREADLOCAL_LOOP.with(|slot| { + let loop_ = slot.get(); + if loop_.is_null() { + return false; + } + let mut ran = false; + // SAFETY: live per-thread loop; `count` is the active arm of the + // union whenever the loop is initialised (uv/win.h). + unsafe { + while (*loop_).active_reqs.count > 0 { + log!("drain_requests: {} in flight", (*loop_).active_reqs.count); + uv_run(loop_, RunMode::Once); + ran = true; + } + } + ran + }) + } + + /// Closes this thread's libuv loop. Called from `WebWorker::shutdown` after + /// the thread's uws loop has been freed and the event loop's pending + /// keep-alive delta has been folded (Bun's virtual keep-alive count shares + /// `active_handles` with libuv, so an unbalanced ref would keep the loop + /// alive forever). Every handle Bun registered on the loop must have been + /// closed while its owner was alive; what remains here is uSockets' own + /// pre/check/async/timer, closed by us_loop_free and freed by their close + /// callbacks when the loop next turns. + pub fn close_thread_loop() { THREADLOCAL_LOOP.with(|slot| { let loop_ = slot.get(); if loop_.is_null() { @@ -437,17 +489,37 @@ impl Loop { } // SAFETY: `loop_` is the live per-thread loop initialized in `get()`. if let Some(err) = unsafe { uv_loop_close(loop_) }.raw_errno() { - // Only EBUSY means handles are - // still open; walk + close them, run once to flush close - // callbacks, then close again (must succeed). `uv_loop_close` - // documents no other failure code. + // Only EBUSY means handles are still linked; walk + close any not + // already closing, run to flush close callbacks and endgames, then + // close again (must succeed). `uv_loop_close` documents no other + // failure code. if err == (UV_EBUSY as c_int).unsigned_abs() as u16 { + // Anything open and not already closing here was left by an + // owner that never closed it; name it under BUN_DEBUG_uv. + // SAFETY: every linked handle's storage is still allocated + // (owners are freed only after this returns). + unsafe { uv_walk(loop_, Some(log_unclosed_cb), ptr::null_mut()) }; unsafe { uv_walk(loop_, Some(close_walk_cb), ptr::null_mut()) }; - let _ = unsafe { uv_run(loop_, RunMode::Default) }; - // NOTE the call is unconditional — the close must run in - // release builds too. - let rc = unsafe { uv_loop_close(loop_) }; - debug_assert_eq!(rc, ReturnCode::ZERO); + // Everything is closing now; only close callbacks / endgames + // remain. Turn the loop without blocking until they have run — + // RunMode::Default would also wait on ref'd-but-idle state + // (Bun's virtual keep-alive count lives in active_handles) and + // never return. + let mut rc = ReturnCode::ZERO; + for _ in 0..64 { + // SAFETY: this thread's initialised loop; nothing else drives it. + let _ = unsafe { uv_run(loop_, RunMode::NoWait) }; + // SAFETY: as above. + rc = unsafe { uv_loop_close(loop_) }; + if rc == ReturnCode::ZERO { + break; + } + } + debug_assert_eq!( + rc, + ReturnCode::ZERO, + "uv loop still busy after closing every handle" + ); } } slot.set(ptr::null_mut()); @@ -535,6 +607,44 @@ impl Loop { } } +/// `Loop::close_thread_loop` diagnostics: which handles keep the worker's loop busy. +unsafe extern "C" fn log_unclosed_cb(handle: *mut uv_handle_t, data: *mut c_void) { + // SAFETY: libuv passes live handles. + if unsafe { uv_is_closing(handle) } == 0 { + // SAFETY: as above. + unsafe { log_walk_cb(handle, data) }; + } +} + +/// # Safety +/// `handle` is a live libuv handle (only its header is read). +unsafe fn handle_type_name<'a>(handle: *mut uv_handle_t) -> &'a str { + // SAFETY: fn contract; libuv returns a static C string or null. + unsafe { + let name = uv_handle_type_name(uv_handle_get_type(handle)); + if name.is_null() { + "?" + } else { + core::ffi::CStr::from_ptr(name).to_str().unwrap_or("?") + } + } +} + +unsafe extern "C" fn log_walk_cb(handle: *mut uv_handle_t, _data: *mut c_void) { + // SAFETY: libuv passes a live handle; these calls only read its header. + unsafe { + log!( + "handle left open by its owner: {} @{:p} active={} closing={} ref={} data={:p}", + handle_type_name(handle), + handle, + uv_is_active(handle), + uv_is_closing(handle), + uv_has_ref(handle), + (*handle).data + ); + } +} + unsafe extern "C" fn close_walk_cb(handle: *mut uv_handle_t, _data: *mut c_void) { // SAFETY: libuv passes a live handle. if unsafe { uv_is_closing(handle) } == 0 { @@ -613,6 +723,7 @@ pub unsafe trait UvHandle: Sized { /// `*mut Self`. ABI-identical to `uv_close_cb` modulo the pointee type. #[inline] fn close(&mut self, cb: unsafe extern "C" fn(*mut Self)) { + open_handles::remove(self.as_handle_mut()); // SAFETY: `Self` embeds `uv_handle_t` at offset 0; cb is ABI-identical. unsafe { uv_close( @@ -1177,7 +1288,11 @@ impl Pipe { #[inline] pub fn init(&mut self, loop_: *mut Loop, ipc: bool) -> ReturnCode { // SAFETY: `self` is a valid `uv_pipe_t`-sized allocation. - unsafe { uv_pipe_init(loop_, self, if ipc { 1 } else { 0 }) } + let rc = unsafe { uv_pipe_init(loop_, self, if ipc { 1 } else { 0 }) }; + if rc.0 == 0 { + open_handles::add_pipe(self); + } + rc } #[inline] pub fn open(&mut self, file: uv_file) -> ReturnCode { @@ -1267,6 +1382,16 @@ impl Pipe { /// registered `uv_close` callback is assumed to free the box; /// if a non-freeing callback was registered, the pipe leaks. pub unsafe fn close_and_destroy(this: *mut Pipe) { + open_handles::remove(this.cast()); + // SAFETY: caller contract. + unsafe { Self::close_and_destroy_unlisted(this) } + } + + /// [`close_and_destroy`] for a pipe already taken off the open-handles list. + /// + /// # Safety + /// As [`close_and_destroy`]. + pub(crate) unsafe fn close_and_destroy_unlisted(this: *mut Pipe) { unsafe extern "C" fn on_close_destroy(handle: *mut Pipe) { // SAFETY: handle was Box-allocated; callback fires exactly once. drop(unsafe { Box::from_raw(handle) }); @@ -1343,7 +1468,13 @@ impl uv_tty_t { #[inline] pub fn init(&mut self, loop_: *mut Loop, file: uv_file) -> ReturnCode { // SAFETY: self is a valid `uv_tty_t`-sized allocation. - unsafe { uv_tty_init(loop_, self, file, 0) } + let rc = unsafe { uv_tty_init(loop_, self, file, 0) }; + // fd 0 is the process-static stdin tty (never freed, shared across + // threads by design); everything else is a heap tty owned by this thread. + if rc.0 == 0 && file != 0 { + open_handles::add_tty(self); + } + rc } #[inline] pub fn set_mode(&mut self, mode: TtyMode) -> ReturnCode { @@ -1574,7 +1705,11 @@ impl Process { #[inline] pub fn spawn(&mut self, loop_: *mut Loop, options: *const uv_process_options_t) -> ReturnCode { // SAFETY: `self` is a valid `uv_process_t`-sized allocation. - unsafe { uv_spawn(loop_, self, options) } + let rc = unsafe { uv_spawn(loop_, self, options) }; + if rc.0 == 0 { + open_handles::add_process(self); + } + rc } #[inline] pub fn kill(&mut self, signum: c_int) -> ReturnCode { diff --git a/src/libuv_sys/open_handles.rs b/src/libuv_sys/open_handles.rs new file mode 100644 index 000000000000..7832c71fa0a6 --- /dev/null +++ b/src/libuv_sys/open_handles.rs @@ -0,0 +1,227 @@ +//! Registry of the uv handles Bun itself opened on this thread's loop (pipes, +//! ttys, processes …), each with the owner that drives it, so a worker's +//! teardown can close every one *through its owner* before the loop is closed +//! — pending writes then complete (ECANCELED) against a live VM and nothing is +//! left for `uv_loop_close` to trip over. +//! +//! This is Node's mechanism: every `HandleWrap` links itself into +//! `Environment::handle_wrap_queue_` (node/src/handle_wrap.h, env.h) at +//! construction and `Environment::CleanupHandles()` (node/src/env.cc) walks that +//! list calling `Close()`; `uv_walk` alone is not enough because it yields bare +//! `uv_handle_t*`s with no typed owner to close through. Insert on open, remove +//! on close — both keyed by address, so neither costs more as handles pile up. +//! +//! A reader over a *file* has no handle — its `uv_fs_read` is a request, which +//! cannot be closed and completes only when the loop is drained — so the reader +//! lists the boxed `File` it reads through (`add_file`, a stable address however +//! the reader itself moves) with itself as owner; the stop phase closes it the +//! same way, and the drained completion then finds a closed reader instead of a +//! parent that is gone or may no longer run script. + +use super::*; + +use core::cell::RefCell; +use std::collections::HashMap; + +/// How a teardown closes a handle that has an owner: `close(owner)`. +pub type CloseViaOwner = unsafe fn(owner: *mut c_void); + +struct Entry { + kind: Kind, + owner: *mut c_void, + close_via_owner: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Kind { + Pipe, + Tty, + Process, +} + +/// A file a reader holds: closed through that reader. +struct FileEntry { + owner: *mut c_void, + close_via_owner: Option, +} + +#[derive(Default)] +struct Open { + /// By handle address. + handles: HashMap<*mut uv_handle_t, Entry>, + /// By the boxed `File`'s address (what the reader's `Source` owns). + files: HashMap<*mut c_void, FileEntry>, +} + +std::thread_local! { + static OPEN: RefCell = RefCell::new(Open::default()); +} + +fn add(handle: *mut uv_handle_t, kind: Kind) { + OPEN.with(|o| { + let previous = o.borrow_mut().handles.insert( + handle, + Entry { + kind, + owner: ptr::null_mut(), + close_via_owner: None, + }, + ); + debug_assert!(previous.is_none(), "uv handle registered twice"); + }); +} + +pub(super) fn add_pipe(p: *mut Pipe) { + add(p.cast(), Kind::Pipe); +} +pub(super) fn add_tty(t: *mut uv_tty_t) { + add(t.cast(), Kind::Tty); +} +pub(super) fn add_process(p: *mut Process) { + add(p.cast(), Kind::Process); +} + +/// The `uv_close` for `handle` has been (or is about to be) issued. +pub(super) fn remove(handle: *mut uv_handle_t) { + OPEN.with(|o| { + o.borrow_mut().handles.remove(&handle); + }); +} + +/// A reader took a file as its source: list the boxed `File` (its address is +/// stable however the reader moves). The owner follows via [`set_file_owner`]; +/// [`remove_file`] when the reader hands the box to libuv or drops it. +pub fn add_file(file: *mut c_void) { + OPEN.with(|o| { + o.borrow_mut().files.entry(file).or_insert(FileEntry { + owner: ptr::null_mut(), + close_via_owner: None, + }); + }); +} + +pub fn remove_file(file: *mut c_void) { + OPEN.with(|o| { + o.borrow_mut().files.remove(&file); + }); +} + +/// The reader that now drives `file` (called again whenever that reader +/// settles at a new address). No-op for a file no reader listed — writers +/// share `Source::set_owner` and never list. +pub fn set_file_owner(file: *mut c_void, owner: *mut c_void, close: CloseViaOwner) { + OPEN.with(|o| { + if let Some(e) = o.borrow_mut().files.get_mut(&file) { + e.owner = owner; + e.close_via_owner = if owner.is_null() { None } else { Some(close) }; + } + }); +} + +/// `owner` now drives `handle` and closes it via `close(owner)`; pass a +/// null `owner` to clear. No-op for handles not listed (never initialised +/// on this thread, already closing, or the process-static stdin tty). +pub fn set_owner(handle: *mut uv_handle_t, owner: *mut c_void, close: Option) { + OPEN.with(|o| { + if let Some(e) = o.borrow_mut().handles.get_mut(&handle) { + e.owner = owner; + e.close_via_owner = if owner.is_null() { None } else { close }; + } + }); +} + +#[cfg(debug_assertions)] +pub fn count() -> usize { + OPEN.with(|o| { + let o = o.borrow(); + o.handles.len() + o.files.len() + }) +} + +enum Next { + Handle(*mut uv_handle_t, Entry), + File(*mut c_void, FileEntry), +} + +/// One entry out of the registry, if any is left. Owners may close other +/// handles from their callbacks, so the stop phase takes one at a time and +/// never holds the borrow across a close. +fn take_next() -> Option { + OPEN.with(|o| { + let mut o = o.borrow_mut(); + if let Some(&file) = o.files.keys().next() { + let entry = o.files.remove(&file).unwrap(); + return Some(Next::File(file, entry)); + } + let &handle = o.handles.keys().next()?; + let entry = o.handles.remove(&handle).unwrap(); + Some(Next::Handle(handle, entry)) + }) +} + +/// Thread teardown's stop phase (VM alive, script forbidden): close every open pipe / +/// tty / process handle and every reader mid file-read — through its owner when +/// it has one, directly when nothing adopted it. +pub fn stop_all_for_vm_teardown() { + while let Some(next) = take_next() { + let (handle, e) = match next { + Next::File( + file, + FileEntry { + owner, + close_via_owner, + }, + ) => { + log!( + "teardown: closing reader over file @{:p} (owner {:p})", + file, + owner + ); + // A listed file always has its reader as owner: `set_source` + // lists and records it in one step, `set_parent` keeps it current. + let close = close_via_owner.expect("file listed without its reader"); + // SAFETY: the reader unlists the file before handing it off or + // dropping it, and re-records itself whenever it moves. + unsafe { close(owner) }; + continue; + } + Next::Handle(handle, e) => (handle, e), + }; + log!( + "teardown: closing open {} handle @{:p} (owner {:p})", + match e.kind { + Kind::Pipe => "pipe", + Kind::Tty => "tty", + Kind::Process => "process", + }, + handle, + e.owner + ); + match (e.close_via_owner, e.kind) { + // SAFETY: the owner recorded itself for this live handle and clears + // or replaces the slot before it goes away (set_owner contract). + (Some(close), _) => unsafe { close(e.owner) }, + // SAFETY: listed ⇒ initialised on this thread and not closing; a + // pipe/tty nobody adopted is a leaked Box handed to libuv here. + (None, Kind::Pipe) => unsafe { Pipe::close_and_destroy_unlisted(handle.cast()) }, + // SAFETY: as above (a leaked Box nobody adopted). + (None, Kind::Tty) => unsafe { + unsafe extern "C" fn free_tty(t: *mut uv_tty_t) { + // SAFETY: heap tty (stdin's static tty is never listed). + drop(unsafe { Box::from_raw(t) }); + } + uv_close( + handle, + Some(mem::transmute::< + unsafe extern "C" fn(*mut uv_tty_t), + unsafe extern "C" fn(*mut uv_handle_t), + >(free_tty)), + ); + }, + // A process handle is embedded in its owner and always adopted at + // spawn; an unowned one cannot be freed safely — close in place. + // SAFETY: listed ⇒ an initialised, not-closing handle on this loop. + (None, Kind::Process) => unsafe { uv_close(handle, None) }, + } + } +} diff --git a/src/runtime/api/Archive.rs b/src/runtime/api/Archive.rs index 518d61c5da0b..33922cf458e1 100644 --- a/src/runtime/api/Archive.rs +++ b/src/runtime/api/Archive.rs @@ -7,14 +7,9 @@ use crate::webcore::BlobExt as _; use crate::webcore::blob::{Store as BlobStore, StoreRef}; use bun_core::zig_string::Slice as ZigStringSlice; use bun_core::{self, Output, ZBox, strings}; -use bun_event_loop::{TaskTag, Taskable, task_tag}; use bun_glob as glob; -use bun_io::KeepAlive; -use bun_jsc::ConcurrentTask::{AutoDeinit, ConcurrentTask}; -use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::{ self as jsc, CallFrame, JSGlobalObject, JSMap, JSPromise, JSPromiseStrong, JSValue, JsResult, - WorkPool, WorkPoolTask, }; use bun_jsc::{StringJsc as _, SysErrorJsc as _}; use bun_libarchive as libarchive; @@ -677,125 +672,50 @@ impl PromiseResult { } } -/// Context must provide: -/// - `run` — runs on thread pool, stores result in `self` -/// - `run_from_js` — returns value to resolve/reject -/// - `Drop` — cleanup -pub trait TaskContext: Send { - /// Dispatch tag for this context's `AsyncTask` variant. - const TAG: TaskTag; +/// One `Bun.Archive` operation's pool-side work: `run` on the thread pool +/// stores its result on `self`; `run_from_js` turns it into the promise's +/// value. It is the off-thread part of an `AsyncTask` job. +pub trait TaskContext: Send + 'static { /// Runs on thread pool. Stores its result on `self`. fn run(&mut self); fn run_from_js(&mut self, global: &JSGlobalObject) -> JsResult; } -/// Generic async task that handles all the boilerplate for thread pool tasks. -pub struct AsyncTask { - ctx: C, - promise: JSPromiseStrong, - vm: *mut VirtualMachine, - task: WorkPoolTask, - concurrent_task: ConcurrentTask, - keep_alive: KeepAlive, -} - -impl Taskable for AsyncTask { - const TAG: TaskTag = C::TAG; -} - -impl AsyncTask { - fn create(global: &JSGlobalObject, ctx: C) -> Result<*mut Self, bun_alloc::AllocError> { - // `bun_vm_ptr()` returns `*mut VirtualMachine` with write provenance; valid for - // process lifetime. Do NOT launder `bun_vm()` (a `&VirtualMachine`) through - // `*const _ as *mut _` — that derives a writeable pointer from a shared - // reference and is UB under Stacked Borrows. - let vm: *mut VirtualMachine = global.bun_vm_ptr(); - let this = Box::new(AsyncTask { - ctx, - promise: JSPromiseStrong::init(global), - vm, - task: WorkPoolTask { - callback: Self::run_callback, - node: Default::default(), - }, - concurrent_task: ConcurrentTask::default(), - keep_alive: KeepAlive::default(), - }); - let raw = bun_core::heap::into_raw(this); - // SAFETY: raw was just produced by heap::alloc; not yet shared. Keep the event - // loop alive until `run_from_js` unrefs after the threadpool work completes. - unsafe { (*raw).keep_alive.ref_(bun_io::js_vm_ctx()) }; - Ok(raw) - } - - fn schedule(this: *mut Self) { - // SAFETY: `this` is alive (owned by the task system) until run_from_js drops it; - // task field is intrusive and stable since `this` is heap-allocated. - WorkPool::schedule(unsafe { &raw mut (*this).task }); +/// The job for a `TaskContext`: the context off-thread, its promise on the JS side. +pub struct AsyncTask(core::marker::PhantomData); + +impl bun_jsc::JobContext for AsyncTask { + type OffThread = C; + type Js = JSPromiseStrong; + fn run( + ctx: &mut C, + _vm: &bun_jsc::vm_handle::Borrow, + done: bun_jsc::Completion, + ) -> Option> { + ctx.run(); + Some(done) } - - /// Read the pending promise's `JSValue` from a freshly-`create`d task. - /// - /// Centralises the `*mut Self → field` deref so the four - /// `start_*_task` callers stay safe. Sound because every caller passes the - /// pointer returned by [`create`](Self::create) (heap-allocated, sole owner - /// on the JS thread) and reads the promise *before* [`schedule`] hands the - /// allocation to the thread pool — i.e. `this` is live and unaliased. - #[inline] - fn promise_value(this: *mut Self) -> JSValue { - // SAFETY: see fn doc — `this` is the live, unscheduled `heap::into_raw` - // allocation from `create()`. - unsafe { (*this).promise.value() } - } - - /// Thread-pool callback (safe fn — coerces to the `WorkPoolTask.callback` - /// field type at the struct-init site in `create`). - fn run_callback(work_task: *mut WorkPoolTask) { - // SAFETY: `work_task` points to the `task` field of an `AsyncTask` - // allocated by `create` — only ever invoked by the thread pool against - // a task it scheduled, so provenance covers the full allocation. - let this: *mut Self = unsafe { bun_core::from_field_ptr!(Self, task, work_task) }; - // SAFETY: thread-pool has exclusive access to ctx until it enqueues the concurrent task. - unsafe { (*this).ctx.run() }; - // SAFETY: vm points to the live owning VM; concurrent_task is intrusive on the same allocation. - unsafe { - let ct = core::ptr::NonNull::from( - (*this).concurrent_task.from(this, AutoDeinit::ManualDeinit), - ); - (*(*this).vm).enqueue_task_concurrent(ct); - } - } - - /// # Safety - /// `this` must be the live `heap::into_raw` allocation produced by - /// [`create`](Self::create), called exactly once on the JS thread after - /// `run_callback` enqueues it. Takes ownership of the allocation. - // Forwards `this` to `bun_core::heap::take` without dereferencing it here; - // not_unsafe_ptr_arg_deref is a false positive on opaque-token forwarding. - #[allow(clippy::not_unsafe_ptr_arg_deref)] - pub(crate) fn run_from_js(this: *mut Self) -> Result<(), bun_jsc::JsTerminated> { - // SAFETY: see fn-level safety contract. - let mut owned = unsafe { bun_core::heap::take(this) }; - owned.keep_alive.unref(bun_io::js_vm_ctx()); - - // `defer { ctx.deinit; destroy(this) }` — handled by `owned: Box` dropping at scope - // exit (ctx implements Drop). - - let vm = VirtualMachine::get(); - if vm.is_shutting_down() { - return Ok(()); - } - - let global = vm.global(); - let promise = owned.promise.swap(); - let result = match owned.ctx.run_from_js(global) { + fn then(mut ctx: C, mut promise: JSPromiseStrong, cx: &bun_jsc::JsThread<'_>) -> JsResult<()> { + let global = cx.global(); + let promise = promise.swap(); + let result = match ctx.run_from_js(global) { Ok(r) => r, Err(e) => { // JSError means exception is already pending - return promise.reject(global, Ok(global.take_exception(e))); + return Ok(promise.reject(global, Err(e))?); } }; - result.fulfill(global, promise) + Ok(result.fulfill(global, promise)?) + } +} + +impl AsyncTask { + /// Schedule `ctx` on the work pool; returns the promise it settles. + fn start(global: &JSGlobalObject, ctx: C) -> JSValue { + let promise = JSPromiseStrong::init(global); + let value = promise.value(); + bun_jsc::Job::::schedule(&global.js_thread(), ctx, promise); + value } } @@ -822,8 +742,6 @@ pub struct ExtractContext { } impl TaskContext for ExtractContext { - const TAG: TaskTag = task_tag::ArchiveExtractTask; - fn run(&mut self) { self.result = self.do_run(); } @@ -889,7 +807,7 @@ fn start_extract_task( let store = store.clone(); // errdefer store.deref() — Drop handles it - let task = ExtractTask::create( + Ok(ExtractTask::start( global, ExtractContext { store, @@ -897,11 +815,7 @@ fn start_extract_task( glob_patterns, result: ExtractResult::Err(ExtractError::ReadError), }, - )?; - - let promise_js = ExtractTask::promise_value(task); - ExtractTask::schedule(task); - Ok(promise_js) + )) } #[derive(Clone, Copy)] @@ -932,8 +846,6 @@ pub struct BlobContext { } impl TaskContext for BlobContext { - const TAG: TaskTag = task_tag::ArchiveBlobTask; - fn run(&mut self) { self.result = match &self.compress { Compression::Gzip(opts) => match compress_gzip(self.store.shared_view(), opts.level) { @@ -960,7 +872,7 @@ impl TaskContext for BlobContext { } BlobOutputType::Bytes => { // Ownership transfers to JSC's `MarkedArrayBuffer_deallocator`. - JSValue::create_buffer_from_box(global, data.into_boxed_slice()) + JSValue::create_buffer_from_box(global, data.into_boxed_slice())? } })) } @@ -984,7 +896,7 @@ impl TaskContext for BlobContext { PromiseResult::Resolve(JSValue::create_buffer_from_box( global, dup.into_boxed_slice(), - )) + )?) } }), } @@ -1002,7 +914,7 @@ fn start_blob_task( let store = store.clone(); // errdefer store.deref() — Drop handles it - let task = BlobTask::create( + Ok(BlobTask::start( global, BlobContext { store, @@ -1010,11 +922,7 @@ fn start_blob_task( output_type, result: BlobResult::Uncompressed, }, - )?; - - let promise_js = BlobTask::promise_value(task); - BlobTask::schedule(task); - Ok(promise_js) + )) } #[derive(thiserror::Error, strum::IntoStaticStr, Debug)] @@ -1044,8 +952,6 @@ pub struct WriteContext { } impl TaskContext for WriteContext { - const TAG: TaskTag = task_tag::ArchiveWriteTask; - fn run(&mut self) { self.result = self.do_run(); } @@ -1110,7 +1016,7 @@ fn start_write_task( // Ref store if using store reference — already done by caller via Arc::clone into WriteData::Store. // errdefer store.deref / free(data.owned) — handled by WriteData Drop on early return. - let task = WriteTask::create( + Ok(WriteTask::start( global, WriteContext { data, @@ -1118,11 +1024,7 @@ fn start_write_task( compress, result: WriteResult::Success, }, - )?; - - let promise_js = WriteTask::promise_value(task); - WriteTask::schedule(task); - Ok(promise_js) + )) } struct FileEntry { @@ -1252,8 +1154,6 @@ impl FilesContext { } impl TaskContext for FilesContext { - const TAG: TaskTag = task_tag::ArchiveFilesTask; - fn run(&mut self) { self.result = match self.do_run() { Ok(r) => r, @@ -1312,18 +1212,14 @@ fn start_files_task( // Ownership: On error, caller's errdefer frees glob_patterns. // On success, ownership transfers to FilesContext, which frees them in deinit(). - let task = FilesTask::create( + Ok(FilesTask::start( global, FilesContext { store, glob_patterns, result: FilesResult::Err(FilesError::ReadError), }, - )?; - - let promise_js = FilesTask::promise_value(task); - FilesTask::schedule(task); - Ok(promise_js) + )) } // ============================================================================ diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index 4ce0dd4aade8..c60099ace37c 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -70,6 +70,7 @@ pub(crate) fn get_public_path_with_asset_prefix( } } +use bun_jsc::HostReturn as _; use core::ffi::c_void; use std::io::Write as _; @@ -892,7 +893,7 @@ pub fn get_main(global_this: &JSGlobalObject) -> JSValue { return vm .main_resolved_path .to_js(global_this) - .unwrap_or(JSValue::ZERO); + .or_pending_exception(); } ZigString::init(vm.main()).to_js(global_this) @@ -1617,15 +1618,11 @@ fn serve(global_object: &JSGlobalObject, callframe: &CallFrame) -> JsResult, pub is_compress: bool, pub level: i32, pub output: Vec, pub error_message: Option<&'static [u8]>, - pub promise: jsc::JSPromiseStrong, } - impl jsc::AnyTaskJobCtx for ZstdCtx { - fn run(&mut self, _global: *mut JSGlobalObject) { - let input = self.buffer.slice(); + impl jsc::JobContext for ZstdJob { + type OffThread = Self; + type Js = jsc::JSPromiseStrong; + + fn run( + this: &mut Self, + _vm: &jsc::vm_handle::Borrow, + done: bun_jsc::Completion, + ) -> Option> { + let input = this.buffer.slice(); - if self.is_compress { - // Compression path - // Calculate max compressed size + if this.is_compress { let max_size = bun_zstd::compress_bound(input.len()); - // Surface OOM - // as a rejected promise instead of aborting. The zero-fill is - // output-irrelevant (zstd overwrites the prefix it reports). + // Surface OOM as a rejected promise instead of aborting. The + // zero-fill is output-irrelevant (zstd overwrites the prefix it reports). let mut output: Vec = Vec::new(); if output.try_reserve_exact(max_size).is_err() { - self.error_message = Some(b"Out of memory"); - return; + this.error_message = Some(b"Out of memory"); + return Some(done); } output.resize(max_size, 0); - self.output = output; + this.output = output; - // Perform compression - self.output = match bun_zstd::compress(&mut self.output, input, Some(self.level)) { + this.output = match bun_zstd::compress(&mut this.output, input, Some(this.level)) { bun_zstd::Result::Success(size) => 'blk: { - // Resize to actual compressed size - if size < self.output.len() { - let mut out = core::mem::take(&mut self.output); + if size < this.output.len() { + let mut out = core::mem::take(&mut this.output); out.truncate(size); out.shrink_to_fit(); break 'blk out; } - break 'blk core::mem::take(&mut self.output); + break 'blk core::mem::take(&mut this.output); } bun_zstd::Result::Err(err) => { - self.output = Vec::new(); - self.error_message = Some(err); - return; + this.output = Vec::new(); + this.error_message = Some(err); + return Some(done); } }; } else { - // Decompression path - self.output = match bun_zstd::decompress_alloc(input) { + this.output = match bun_zstd::decompress_alloc(input) { Ok(v) => v, Err(_) => { - self.error_message = Some(b"Decompression failed"); - return; + this.error_message = Some(b"Decompression failed"); + return Some(done); } }; } + Some(done) } - fn then(&mut self, global_this: &JSGlobalObject) -> JsResult<()> { - let promise = self.promise.swap(); + fn then( + mut this: Self, + mut promise: jsc::JSPromiseStrong, + cx: &jsc::JsThread<'_>, + ) -> JsResult<()> { + let global_this = cx.global(); + let promise = promise.swap(); - if let Some(err_msg) = self.error_message { + if let Some(err_msg) = this.error_message { promise.reject_with_async_stack( global_this, Ok(global_this @@ -2920,42 +2924,33 @@ pub mod JSZstd { return Ok(()); } - let output_slice = core::mem::take(&mut self.output); + let output_slice = core::mem::take(&mut this.output); let buffer_value = JSValue::create_buffer(global_this, output_slice.leak()); - promise.resolve(global_this, buffer_value)?; + promise.settle(global_this, buffer_value)?; Ok(()) } } - /// Free fn (not `impl ZstdJob`) because - /// `AnyTaskJob<_>` is a foreign type. Returns the promise `JSValue` - /// directly so callers stay safe (the only state read back from the heap - /// job is `ctx.promise.value()`; capture it before moving the strong into - /// the ctx so no post-schedule raw deref is needed). fn create_job( global_this: &JSGlobalObject, buffer: node::StringOrBuffer, is_compress: bool, level: i32, ) -> JSValue { + let cx = global_this.js_thread(); let promise = jsc::JSPromiseStrong::init(global_this); let promise_value = promise.value(); - let job = jsc::AnyTaskJob::create( - global_this, - ZstdCtx { - // Caller passed `from_js_maybe_async(.., is_async=true)`; adopt - // so the protect ref is paired with drop. + jsc::Job::::schedule( + &cx, + ZstdJob { buffer: bun_jsc::ThreadSafe::adopt(buffer), is_compress, level, output: Vec::new(), error_message: None, - promise, }, - ) - .expect("ZstdCtx::init is infallible"); - // SAFETY: `job` is a freshly-created live pointer. - unsafe { jsc::AnyTaskJob::schedule(job) }; + promise, + ); promise_value } diff --git a/src/runtime/api/JSBundler.rs b/src/runtime/api/JSBundler.rs index fff3cd6b6371..f70ee676d02e 100644 --- a/src/runtime/api/JSBundler.rs +++ b/src/runtime/api/JSBundler.rs @@ -548,11 +548,13 @@ pub mod js_bundler { if let Some(promise) = plugin_result.as_any_promise() { promise.set_handled(global_this.vm()); // SAFETY: bun_vm() returns the live process VirtualMachine pointer. - global_this.bun_vm().as_mut().wait_for_promise(promise); + global_this.bun_vm().as_mut().wait_for_promise(promise)?; match promise .unwrap(global_this.vm(), jsc::PromiseUnwrapMode::MarkHandled) { - jsc::PromiseResult::Pending => unreachable!(), + jsc::PromiseResult::Pending => { + unreachable!("wait_for_promise returned Ok") + } jsc::PromiseResult::Fulfilled(val) => { plugin_result = val; } @@ -1364,8 +1366,6 @@ pub mod js_bundler { let mut plugins: Option<*mut Plugin> = None; let config = Config::from_js(global_this, arguments[0], &mut plugins)?; - let event_loop = vm.event_loop(); - // `BundleV2.generateFromJavaScript` — the completion-task struct lives in // `crate::api::js_bundle_completion_task` (bun_runtime owns it because its // fields name `Config`/`Plugin`/`HTMLBundle::Route`; lower-tier crates @@ -1375,7 +1375,6 @@ pub mod js_bundler { config, plugins.and_then(core::ptr::NonNull::new), global_this, - event_loop, ) .map_err(|_| JsError::OutOfMemory)?; // SAFETY: `completion` is the freshly-boxed allocation returned above; @@ -1523,11 +1522,17 @@ pub mod js_bundler { .r#loop() .expect("BundleV2.linker.loop must be set before plugins run"); match &mut *any_loop.as_ptr() { - bun_event_loop::AnyEventLoop::Js { owner } => { - owner.enqueue_task_concurrent(ConcurrentTask::from_callback( - ctx.as_mut_ptr(), - on_notify_defer_raw, - )); + bun_event_loop::AnyEventLoop::Js { .. } => { + let ct = + ConcurrentTask::from_callback(ctx.as_mut_ptr(), on_notify_defer_raw); + let poster = (*ctx.as_mut_ptr()) + .js_poster + .as_ref() + .expect("JS-owned bundle has a poster"); + if let bun_event_loop::Posted::Refused(ct) = poster.post(ct) { + // Owning JS VM torn down mid-bundle: the notify never runs. + bun_event_loop::ConcurrentTask::ConcurrentTask::release_refused(ct); + } } bun_event_loop::AnyEventLoop::Mini(mini) => { // `mini.enqueueTaskConcurrentWithExtraCtx( @@ -1680,6 +1685,9 @@ pub mod js_bundler { /// `this` must be a live handle previously returned by `Plugin::create`; /// non-null is checked via `Plugin::opaque_ref` (panics on null). fn destroy(this: *mut Plugin); + /// From here the plugin object swallows whatever its JS side still + /// delivers (onLoad/onResolve answers, defer, addError). JS thread. + fn tombstone(&self); fn global_object(&self) -> &JSGlobalObject; fn append_defer_promise(&mut self) -> JSValue; fn add_plugin( @@ -1739,6 +1747,10 @@ pub mod js_bundler { Ok(value) } + fn tombstone(&self) { + JSBundlerPlugin__tombstone(self); + } + fn destroy(this: *mut Plugin) { jsc::mark_binding(); JSBundlerPlugin__tombstone(Plugin::opaque_ref(this)); diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index 7c643d8aa462..02d1ae29b5a2 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -649,98 +649,112 @@ impl Config { // threadlocal var transform_buffer_loaded: bool = false; // This is going to be hard to not leak -pub(crate) struct TransformTask<'a> { - /// Created with `is_async=true` (JS-backed buffer protected); the - /// [`bun_jsc::ThreadSafe`] guard unprotects on drop. +/// `transpiler.transform()` off the JS thread. The parse/print state points +/// into the owning `JSTranspiler`'s config (its `Transpiler` is bit-copied), +/// which the job's Js side keeps alive and the pool borrow keeps valid. +pub(crate) struct TransformTask { pub input_code: bun_jsc::ThreadSafe, pub output_code: BunString, - /// Bitwise copy of `js_instance.transpiler`. - /// Heap-owned fields (`Box`, resolver caches, …) are *shared* with - /// `js_instance`, which is kept alive by the `IntrusiveRc` below for the - /// task's lifetime. `ManuallyDrop` prevents double-free; the original owns. pub transpiler: core::mem::ManuallyDrop>, - // `IntrusiveRc` (not `Arc`): JSTranspiler uses single-thread intrusive - // `bun.ptr.RefCount` and crosses FFI as `m_ctx` (PORTING.md §Pointers). - pub js_instance: bun_ptr::IntrusiveRc, pub log: bun_ast::Log, pub err: Option, pub macro_map: MacroMap, - pub tsconfig: Option<&'a TSConfigJSON>, + pub tsconfig: Option>, pub loader: Loader, - pub global: &'a JSGlobalObject, pub replace_exports: bun_ast::runtime::ReplaceableExportMap, } +// SAFETY: see the type doc — VM-owned config is read only under the pool +// borrow; everything else is owned. +unsafe impl Send for TransformTask {} + +#[derive(bun_jsc::JsAffine)] +pub(crate) struct TransformJs { + promise: jsc::JSPromiseStrong, + /// The `JSTranspiler` wrapper whose config the task reads. + _transpiler: jsc::Strong, +} -pub(crate) type AsyncTransformTask<'a> = - jsc::concurrent_promise_task::ConcurrentPromiseTask<'a, TransformTask<'a>>; - -impl<'a> jsc::concurrent_promise_task::ConcurrentPromiseTaskContext for TransformTask<'a> { - const TASK_TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::AsyncTransformTask; - fn run(&mut self) { - TransformTask::run(self) +impl jsc::JobContext for TransformTask { + type OffThread = Self; + type Js = TransformJs; + fn run( + this: &mut Self, + vm: &jsc::vm_handle::Borrow, + done: bun_jsc::Completion, + ) -> Option> { + TransformTask::run(this, vm); + Some(done) } - fn then(&mut self, promise: &mut JSPromise) -> Result<(), bun_jsc::JsTerminated> { - TransformTask::then(self, promise) + fn then(mut this: Self, mut js: TransformJs, cx: &jsc::JsThread<'_>) -> JsResult<()> { + Ok(TransformTask::then( + &mut this, + js.promise.swap(), + cx.global(), + )?) } } -impl<'a> TransformTask<'a> { +impl TransformTask { // `pub const new = bun.TrivialNew(@This())` → Box::new - fn create( - transpiler: &'a JSTranspiler, + /// Schedule the transform on the work pool; returns its promise. + fn schedule( + transpiler: &JSTranspiler, + transpiler_js: JSValue, input_code: bun_jsc::ThreadSafe, - global: &'a JSGlobalObject, + global: &JSGlobalObject, loader: Loader, - ) -> Box> { + ) -> JSValue { let config = transpiler.config.get(); let mut log = bun_ast::Log::init(); log.level = config.log.level; - // SAFETY: bitwise struct copy of `transpiler.transpiler`. Heap-owned - // fields are shared with `js_instance` (kept alive via IntrusiveRc); the - // copy is wrapped in `ManuallyDrop` so only the original frees them. + // SAFETY: bitwise copy of the wrapper's Transpiler; `ManuallyDrop` so the + // copy never frees what the original owns. Its self-pointers (log, + // linker.resolver, arena) are re-aimed in `run` once the task has its + // final address inside the job. let transpiler_copy = core::mem::ManuallyDrop::new(unsafe { core::ptr::read(transpiler.transpiler.as_ptr()) }); - let mut transform_task = Box::new(TransformTask { + let task = TransformTask { input_code, output_code: BunString::empty(), transpiler: transpiler_copy, - global, macro_map: clone_macro_map(&config.macro_map), - tsconfig: config.tsconfig.as_deref(), + tsconfig: config + .tsconfig + .as_deref() + // SAFETY: points into the wrapper's config, kept alive by `TransformJs`. + .map(|t| unsafe { jsc::JsPtr::new(core::ptr::NonNull::from(t)) }), log, err: None, loader, replace_exports: bun_ast::runtime::ReplaceableExportMap { entries: config.runtime.replace_exports.entries.clone().expect("OOM"), }, - // SAFETY: `transpiler` is the live `m_ctx` payload; `init_ref` bumps the - // `Cell`-backed count. `as_ctx_ptr` - // yields `*mut Self` from `&Self` — signature-only; the only mutation - // is to the `RefCount` field, which is interior-mutable. - js_instance: unsafe { bun_ptr::IntrusiveRc::init_ref(transpiler.as_ctx_ptr()) }, - }); - - // Re-point the linker's resolver backref into the heap-allocated copy. - // Must happen AFTER the move into the Box so the address is stable. - let resolver_ptr: *mut _ = &raw mut transform_task.transpiler.resolver; - transform_task.transpiler.linker.resolver = resolver_ptr; - transform_task - .transpiler - .set_log(&raw mut transform_task.log); - // `set_arena(bun.default_allocator)` — Rust `Transpiler` carries an - // `&Arena`, not a generic allocator. The work-thread `run()` immediately - // overwrites it with the local arena, so leave the copied pointer as-is - // here (it still points at `js_instance.arena`, which is kept alive). - - AsyncTransformTask::create_on_js_thread(global, transform_task) + }; + let cx = global.js_thread(); + let promise = jsc::JSPromiseStrong::init(global); + let value = promise.value(); + jsc::Job::::schedule( + &cx, + task, + TransformJs { + promise, + _transpiler: jsc::Strong::create(transpiler_js, global), + }, + ); + value } - fn run(&mut self) { + fn run(&mut self, vm: &jsc::vm_handle::Borrow) { let name = self.loader.stdin_name(); + let resolver_ptr: *mut _ = &raw mut self.transpiler.resolver; + self.transpiler.linker.resolver = resolver_ptr; + // SAFETY: the wrapper's config, alive under the borrow (see `schedule`). + let tsconfig: Option<&TSConfigJSON> = + self.tsconfig.map(|p| &*unsafe { p.under_borrow(vm) }); let arena = Arena::new(); @@ -778,7 +792,7 @@ impl<'a> TransformTask<'a> { self.transpiler.set_log(&raw mut self.log); // self.log.msgs.allocator = bun.default_allocator → no-op - let jsx = match self.tsconfig { + let jsx = match tsconfig { Some(ts) => ts.merge_jsx(self.transpiler.options.jsx.clone()), None => self.transpiler.options.jsx.clone(), }; @@ -793,10 +807,9 @@ impl<'a> TransformTask<'a> { path: source.path, virtual_source: Some(source), replace_exports: self.replace_exports.entries.clone().expect("OOM"), - experimental_decorators: self.tsconfig.is_some_and(|ts| ts.experimental_decorators), - emit_decorator_metadata: self.tsconfig.is_some_and(|ts| ts.emit_decorator_metadata), - use_define_for_class_fields: self - .tsconfig + experimental_decorators: tsconfig.is_some_and(|ts| ts.experimental_decorators), + emit_decorator_metadata: tsconfig.is_some_and(|ts| ts.emit_decorator_metadata), + use_define_for_class_fields: tsconfig .and_then(|ts| ts.use_define_for_class_fields) .unwrap_or(true), macro_js_ctx: MacroJSCtx::ZERO, @@ -854,19 +867,19 @@ impl<'a> TransformTask<'a> { } } - fn then(&mut self, promise: &mut JSPromise) -> Result<(), bun_jsc::JsTerminated> { - // After `then` returns, the dispatcher - // (`run_then_destroy!` for `task_tag::AsyncTransformTask` in - // runtime/dispatch.rs) unconditionally calls - // `ConcurrentPromiseTask::destroy`, dropping the owned `ctx` - // (this `TransformTask`) and running its `Drop` (transpiler deref etc.). - + fn then( + &mut self, + promise: &mut JSPromise, + global: &JSGlobalObject, + ) -> Result<(), bun_jsc::JsTerminated> { + // The job drops this `TransformTask` (running its `Drop`: transpiler + // deref etc.) right after `then` returns. if self.log.has_any() || self.err.is_some() { let error_value: JsResult = 'brk: { if let Some(err) = &self.err { if !self.log.has_any() { break 'brk bun_jsc::BuildMessage::create( - self.global, + global, bun_ast::Msg { data: bun_ast::Data { text: err.name().as_bytes().to_vec().into(), @@ -878,30 +891,22 @@ impl<'a> TransformTask<'a> { } } - break 'brk self.log.to_js(self.global, "Transform failed"); + break 'brk self.log.to_js(global, "Transform failed"); }; - promise.reject_with_async_stack(self.global, error_value)?; + promise.reject_with_async_stack(global, error_value)?; return Ok(()); } - self.finish(promise) + self.finish(promise, global) } - fn finish(&mut self, promise: &mut JSPromise) -> Result<(), bun_jsc::JsTerminated> { - match self.output_code.transfer_to_js(self.global) { - Ok(value) => promise.resolve(self.global, value), - Err(e) => promise.reject(self.global, Ok(self.global.take_exception(e))), - } - } -} - -// `js_instance: IntrusiveRc` (= `RefPtr`) has NO Drop impl; its strong ref must be -// released explicitly or the `JSTranspiler` never reaches refcount 0. -impl<'a> Drop for TransformTask<'a> { - fn drop(&mut self) { - // Release the +1 taken in `TransformTask::create`. - bun_ptr::RefPtr::deref(&self.js_instance); + fn finish( + &mut self, + promise: &mut JSPromise, + global: &JSGlobalObject, + ) -> Result<(), bun_jsc::JsTerminated> { + promise.settle(global, self.output_code.transfer_to_js(global)) } } @@ -1193,15 +1198,6 @@ impl Drop for TranspilerStateGuard { impl JSTranspiler { // ─── R-2 interior-mutability helpers ───────────────────────────────────── - /// `self`'s address as `*mut Self` for `IntrusiveRc::init_ref` and similar - /// FFI ctx slots that spell the parameter `*mut`. The only mutation through - /// this pointer goes to `ref_count` (`Cell`-backed) or `JsCell` fields, - /// so no write provenance on the outer `JSTranspiler` is required. - #[inline] - fn as_ctx_ptr(&self) -> *mut Self { - std::ptr::from_ref::(self).cast_mut() - } - /// `*mut Log` to the resting-state `config.log`, projected through the /// `JsCell` (UnsafeCell-backed, so the write provenance is sound). #[inline] @@ -1449,13 +1445,13 @@ impl JSTranspiler { }; let default_loader = self.config.get().default_loader; - let mut task = TransformTask::create(self, code, global, loader.unwrap_or(default_loader)); - let promise = task.promise.value(); - task.schedule(); - // Ownership passes to the work pool / event loop; freed via - // `ConcurrentPromiseTask::destroy` on the `.manual_deinit` path. - let _ = bun_core::heap::into_raw(task); - Ok(promise) + Ok(TransformTask::schedule( + self, + callframe.this(), + code, + global, + loader.unwrap_or(default_loader), + )) } #[bun_jsc::host_fn(method)] diff --git a/src/runtime/api/NativePromiseContext.rs b/src/runtime/api/NativePromiseContext.rs index bc25536cbb92..cc9cf38e0c80 100644 --- a/src/runtime/api/NativePromiseContext.rs +++ b/src/runtime/api/NativePromiseContext.rs @@ -184,6 +184,11 @@ pub(crate) struct DeferredDerefTask; impl Taskable for DeferredDerefTask { const TAG: TaskTag = task_tag::NativePromiseContextDeferredDerefTask; + /// `this` packs a context pointer and tag; the deref it defers is + /// script-free, so do it. + unsafe fn release_unrun(this: *mut Self) { + Self::run_from_js_thread(this as usize); + } } impl DeferredDerefTask { diff --git a/src/runtime/api/bun/Terminal.rs b/src/runtime/api/bun/Terminal.rs index 308182e48dc1..700eb630396d 100644 --- a/src/runtime/api/bun/Terminal.rs +++ b/src/runtime/api/bun/Terminal.rs @@ -1964,8 +1964,16 @@ impl Terminal { // MarkedArrayBuffer::from_bytes takes a `&mut [u8]` it will own (freed // via mimalloc on the C++ side) — leak the Box and hand over the slice. let bytes: &'static mut [u8] = Box::leak(v.into_boxed_slice()); - let data = MarkedArrayBuffer::from_bytes(bytes, jsc::JSType::Uint8Array) - .to_node_buffer(global_this); + let data = match MarkedArrayBuffer::from_bytes(bytes, jsc::JSType::Uint8Array) + .to_node_buffer(global_this) + { + Ok(data) => data, + // OOM / a termination request: that exception is reported by the loop. + Err(err) => { + global_this.report_active_exception_as_unhandled(err); + return true; + } + }; global_this.bun_vm().event_loop_mut().run_callback( callback, diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 1ad7bb0ce521..bc47259df012 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -4048,10 +4048,6 @@ impl H2FrameParser { let mut offset: usize = 0; let global_object = self.handlers.get().global(); - if self.handlers.get().vm.is_shutting_down() { - return Ok(None); - } - let stream_id = stream.id; let headers = JSValue::create_empty_array(&global_object, 0)?; headers.ensure_still_alive(); diff --git a/src/runtime/api/bun/js_bun_spawn_bindings.rs b/src/runtime/api/bun/js_bun_spawn_bindings.rs index 016ae2183482..c9045837f228 100644 --- a/src/runtime/api/bun/js_bun_spawn_bindings.rs +++ b/src/runtime/api/bun/js_bun_spawn_bindings.rs @@ -407,7 +407,7 @@ fn spawn_maybe_sync( let args_type = args.js_type(); if args_type.is_array() { cmd_value = args; - args = secondary_args_value.unwrap_or(JSValue::ZERO); + args = secondary_args_value.unwrap_or_default(); } else if !args.is_object() { return Err(global_this.throw_invalid_arguments(format_args!("cmd must be an array"))); } else if let Some(cmd_value_) = args.get_truthy(global_this, "cmd")? { @@ -1321,6 +1321,8 @@ fn spawn_maybe_sync( })); // SAFETY: subprocess_ptr is a freshly-boxed Subprocess; we hold the only reference. let subprocess = unsafe { &mut *subprocess_ptr }; + #[cfg(windows)] + SubprocessT::record_stdio_pipe_ownership(subprocess_ptr); // Erase the borrow lifetime to 'static for the intrusive back-pointer // (PipeReader stores it as raw NonNull). subprocess_ptr is non-null (just boxed). let subprocess_nn: NonNull> = diff --git a/src/runtime/api/bun/subprocess.rs b/src/runtime/api/bun/subprocess.rs index 1e17989a5791..e8797b6454b1 100644 --- a/src/runtime/api/bun/subprocess.rs +++ b/src/runtime/api/bun/subprocess.rs @@ -936,7 +936,7 @@ impl Subprocess<'_> { #[allow(clippy::not_unsafe_ptr_arg_deref)] pub(crate) fn on_process_exit(&self, process: *mut Process, status: &Status, rusage: &Rusage) { bun_output::scoped_log!(Subprocess, "onProcessExit()"); - let this_jsvalue = self.this_value.get().try_get().unwrap_or(JSValue::ZERO); + let this_jsvalue = self.this_value.get().try_get().unwrap_or_default(); // Copy the BackRef out so the `&JSGlobalObject` borrow is detached from `&self` // (mirrors the original `&'a` return — the global outlives `self`). let global_this = self.global_this; @@ -1256,10 +1256,11 @@ impl Subprocess<'_> { #[cfg(windows)] for item in self.stdio_pipes.replace(Vec::new()) { if let StdioResult::Buffer(buffer) = item { - // `uv_close` is async — the pipe must outlive this scope until - // `on_pipe_close` runs and reclaims the allocation. Hand the - // `Box` back to libuv as a raw pointer. - Box::leak(buffer).close(on_pipe_close); + // `uv_close` is async — the pipe must outlive this scope until the + // close callback reclaims the allocation; `close_and_destroy` also + // copes with a pipe that never got `uv_pipe_init`'d. + // SAFETY: Box-allocated uv::Pipe owned by this slot until now. + unsafe { bun_sys::windows::libuv::Pipe::close_and_destroy(Box::into_raw(buffer)) }; } } #[cfg(not(windows))] @@ -1432,7 +1433,7 @@ impl Subprocess<'_> { } IPC::DecodedIPCMessage::Data(data) => { bun_output::scoped_log!(IPC, "Received IPC message from child"); - let this_jsvalue = self.this_value.get().try_get().unwrap_or(JSValue::ZERO); + let this_jsvalue = self.this_value.get().try_get().unwrap_or_default(); let _keep = jsc::EnsureStillAlive(this_jsvalue); if !this_jsvalue.is_empty() { if let Some(cb) = js::ipc_callback_get_cached(this_jsvalue) { @@ -1465,7 +1466,7 @@ impl Subprocess<'_> { pub(crate) fn handle_ipc_close(&self) { bun_output::scoped_log!(IPC, "Subprocess#handleIPCClose"); - let this_jsvalue = self.this_value.get().try_get().unwrap_or(JSValue::ZERO); + let this_jsvalue = self.this_value.get().try_get().unwrap_or_default(); let _keep = jsc::EnsureStillAlive(this_jsvalue); let global_this = self.global_this; let global_this = global_this.get(); @@ -1539,6 +1540,42 @@ pub(crate) fn source_from_array_buffer(ab: jsc::array_buffer::ArrayBufferStrong) Source::Any(Box::new(ArrayBufferSource(ab))) } +/// Windows: the extra stdio pipes (`stdio_pipes`) are uv handles this +/// Subprocess owns without a reader/writer in front of them; record that so a +/// thread teardown closes them through us (and `finalize_streams` then finds +/// the slots empty) instead of anyone closing them twice. +#[cfg(windows)] +impl Subprocess<'_> { + pub(crate) fn record_stdio_pipe_ownership(this: *mut Self) { + // SAFETY: `this` is the live boxed Subprocess (stable address). + let me = unsafe { &*this }; + for item in me.stdio_pipes.get().iter() { + if let StdioResult::Buffer(buffer) = item { + bun_sys::windows::libuv::open_handles::set_owner( + core::ptr::from_ref::(&**buffer) + .cast_mut() + .cast(), + this.cast(), + Some(Self::stop_for_vm_teardown), + ); + } + } + } + + /// `uv::open_handles` entry point: close every stdio pipe still held here. + unsafe fn stop_for_vm_teardown(this: *mut core::ffi::c_void) { + // SAFETY: recorded by `record_stdio_pipe_ownership` for this live + // Subprocess; each pipe leaves the list as its uv_close is issued. + let me = unsafe { &*this.cast::() }; + for item in me.stdio_pipes.replace(Vec::new()) { + if let StdioResult::Buffer(buffer) = item { + // SAFETY: Box-allocated uv::Pipe owned by this slot until now. + unsafe { bun_sys::windows::libuv::Pipe::close_and_destroy(Box::into_raw(buffer)) }; + } + } + } +} + #[cfg(windows)] pub(crate) extern "C" fn on_pipe_close(this: *mut bun_sys::windows::libuv::Pipe) { // safely free the pipes diff --git a/src/runtime/api/bun/subprocess/Readable.rs b/src/runtime/api/bun/subprocess/Readable.rs index 61d62db74118..947a18da9f06 100644 --- a/src/runtime/api/bun/subprocess/Readable.rs +++ b/src/runtime/api/bun/subprocess/Readable.rs @@ -296,7 +296,7 @@ impl Readable { }; let result = Self::pipe_reader_mut(&pipe).to_buffer(global); Self::pipe_detach(&pipe); - Ok(result) + result } Readable::Buffer(_) => { let Readable::Buffer(mut buf) = mem::replace(self, Readable::Closed) else { @@ -309,12 +309,12 @@ impl Readable { // Ownership of the mimalloc-backed buffer transfers to JSC // (freed via `MarkedArrayBuffer_deallocator`). - Ok(jsc::MarkedArrayBuffer { + jsc::MarkedArrayBuffer { buffer: jsc::ArrayBuffer::from_owned_bytes(own, jsc::JSType::Uint8Array), owns_buffer: true, pinned: false, } - .to_node_buffer(global)) + .to_node_buffer(global) } _ => Ok(JSValue::UNDEFINED), } diff --git a/src/runtime/api/bun/subprocess/SubprocessPipeReader.rs b/src/runtime/api/bun/subprocess/SubprocessPipeReader.rs index 86e91a1e5a91..a5863b92fe43 100644 --- a/src/runtime/api/bun/subprocess/SubprocessPipeReader.rs +++ b/src/runtime/api/bun/subprocess/SubprocessPipeReader.rs @@ -124,7 +124,7 @@ impl PipeReader { // `.buffer` payload is a heap-allocated `uv::Pipe`. Ownership // transfers to `reader.source`; `stdio_result` is left `Unavailable`. if let StdioResult::Buffer(pipe) = this.stdio_result.take() { - this.reader.source = Some(bun_io::Source::Pipe(pipe)); + this.reader.set_source(bun_io::Source::Pipe(pipe)); } } @@ -330,7 +330,7 @@ impl PipeReader { } } - pub(crate) fn to_buffer(&mut self, global_this: &JSGlobalObject) -> JSValue { + pub(crate) fn to_buffer(&mut self, global_this: &JSGlobalObject) -> JsResult { match &mut self.state { State::Done(bytes) => { let bytes = core::mem::take(bytes); @@ -343,7 +343,7 @@ impl PipeReader { MarkedArrayBuffer::from_bytes(slice, jsc::JSType::Uint8Array) .to_node_buffer(global_this) } - _ => JSValue::UNDEFINED, + _ => Ok(JSValue::UNDEFINED), } } diff --git a/src/runtime/api/cron.rs b/src/runtime/api/cron.rs index 186b4338ccf5..21841fe869ba 100644 --- a/src/runtime/api/cron.rs +++ b/src/runtime/api/cron.rs @@ -2474,7 +2474,7 @@ unsafe fn spawn_cmd_prepare( // callback + double-free on reader close). if let spawn::WindowsStdioResult::Buffer(pipe) = spawned.stderr.take() { debug_assert!(core::ptr::eq(Box::as_ref(&pipe), stderr_pipe_ptr)); - s!().stderr_reader().source = Some(bun_io::Source::Pipe(pipe)); + s!().stderr_reader().set_source(bun_io::Source::Pipe(pipe)); s!().stderr_reader().set_parent(this_ptr); *s!().remaining_fds() += 1; if s!().stderr_reader().start_with_current_pipe().is_err() { diff --git a/src/runtime/api/glob.rs b/src/runtime/api/glob.rs index 7f448020bcc7..a44ab7a77e96 100644 --- a/src/runtime/api/glob.rs +++ b/src/runtime/api/glob.rs @@ -4,10 +4,9 @@ use bun_alloc::Arena; use bun_core::String as BunString; use bun_glob::BunGlobWalker as GlobWalker; use bun_jsc::bun_string_jsc; -use bun_jsc::concurrent_promise_task::{ConcurrentPromiseTask, ConcurrentPromiseTaskContext}; use bun_jsc::{ - ArgumentsSlice, CallFrame, JSGlobalObject, JSPromise, JSValue, JsResult, JsTerminated, - StringJsc as _, SysErrorJsc as _, + ArgumentsSlice, CallFrame, JSGlobalObject, JSPromiseStrong, JSValue, Job, JobContext, JsPtr, + JsResult, JsThread, StringJsc as _, SysErrorJsc as _, }; use bun_paths::resolve_path::join_string_buf; use bun_paths::{self as resolve_path, MAX_PATH_BYTES, PathBuffer, platform}; @@ -192,12 +191,40 @@ impl ScanOpts { } } -pub(crate) struct WalkTask<'a> { +/// `Glob.scan()` off the JS thread. +pub(crate) struct WalkTask { // `Box` drop runs `GlobWalker::Drop` then frees the box. walker: Box, err: Option, - global: &'a JSGlobalObject, - has_pending_activity: &'a AtomicUsize, +} +// SAFETY: the walker owns its pattern/arena; nothing in it is thread-affine. +unsafe impl Send for WalkTask {} + +/// While a scan is pending the `Glob` wrapper reports `hasPendingActivity` +/// (so it is not collected); released on the JS thread with the completion. +pub(crate) struct PendingScan(JsPtr); +// SAFETY: a counter inside the Glob's native part, which its wrapper owns. +unsafe impl bun_jsc::job::JsAffine for PendingScan {} +impl PendingScan { + fn new(counter: &AtomicUsize) -> Self { + let _ = counter.fetch_add(1, Ordering::SeqCst); + // SAFETY: the Glob's m_ctx, kept alive by hasPendingActivity while > 0. + Self(unsafe { JsPtr::new(core::ptr::NonNull::from(counter)) }) + } +} +impl Drop for PendingScan { + fn drop(&mut self) { + // Only ever dropped on the JS thread (a job's Js side); the pointer is + // live because the count we hold kept the wrapper alive. + // SAFETY: as above. + let _ = unsafe { &*self.0.as_ptr() }.fetch_sub(1, Ordering::SeqCst); + } +} + +#[derive(bun_jsc::JsAffine)] +pub(crate) struct WalkJs { + promise: JSPromiseStrong, + _pending: PendingScan, } pub(crate) enum WalkTaskErr { @@ -216,63 +243,40 @@ impl WalkTaskErr { } } -pub(crate) type AsyncGlobWalkTask<'a> = ConcurrentPromiseTask<'a, WalkTask<'a>>; - -impl<'a> WalkTask<'a> { - fn create( - global_this: &'a JSGlobalObject, - glob_walker: Box, - has_pending_activity: &'a AtomicUsize, - ) -> Box> { - let walk_task = Box::new(WalkTask { - walker: glob_walker, - global: global_this, - err: None, - has_pending_activity, - }); - AsyncGlobWalkTask::create_on_js_thread(global_this, walk_task) - } -} +impl JobContext for WalkTask { + type OffThread = Self; + type Js = WalkJs; -impl<'a> ConcurrentPromiseTaskContext for WalkTask<'a> { - const TASK_TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::AsyncGlobWalkTask; - fn run(&mut self) { - let guard = scopeguard::guard(self.has_pending_activity, |hpa| { - decr_pending_activity_flag(hpa); - }); - let result = match self.walker.walk() { + fn run( + this: &mut Self, + _vm: &bun_jsc::vm_handle::Borrow, + done: bun_jsc::Completion, + ) -> Option> { + let result = match this.walker.walk() { Ok(r) => r, Err(err) => { - self.err = Some(WalkTaskErr::Unknown(err.into())); - drop(guard); - return; + this.err = Some(WalkTaskErr::Unknown(err.into())); + return Some(done); } }; - match result { - bun_sys::Result::Err(err) => { - self.err = Some(WalkTaskErr::Syscall(err)); - } - bun_sys::Result::Ok(()) => {} + if let bun_sys::Result::Err(err) = result { + this.err = Some(WalkTaskErr::Syscall(err)); } - drop(guard); + Some(done) } - fn then(&mut self, promise: &mut JSPromise) -> Result<(), JsTerminated> { - // Ownership of `Box` is held by `ConcurrentPromiseTask.ctx`; the wrapper is - // freed via `ConcurrentPromiseTask::destroy` on the `.manual_deinit` path - // after `run_from_js` returns, which drops `ctx` (and thus `walker`). - - if let Some(err) = &self.err { - promise.reject_with_async_stack(self.global, err.to_js(self.global))?; + fn then(mut this: Self, mut js: WalkJs, cx: &JsThread<'_>) -> JsResult<()> { + let global = cx.global(); + let promise = js.promise.swap(); + if let Some(err) = &this.err { + promise.reject_with_async_stack(global, err.to_js(global))?; return Ok(()); } - - let js_strings = match glob_walk_result_to_js(&mut self.walker, self.global) { + let js_strings = match glob_walk_result_to_js(&mut this.walker, global) { Ok(v) => v, - // `reject()` pulls the pending exception off the VM. - Err(e) => return promise.reject(self.global, Err(e)), + Err(e) => return Ok(promise.reject(global, Err(e))?), }; - promise.resolve(self.global, js_strings) + Ok(promise.resolve(global, js_strings)?) } } @@ -396,14 +400,6 @@ impl Glob { } } -fn incr_pending_activity_flag(has_pending_activity: &AtomicUsize) { - let _ = has_pending_activity.fetch_add(1, Ordering::SeqCst); -} - -fn decr_pending_activity_flag(has_pending_activity: &AtomicUsize) { - let _ = has_pending_activity.fetch_sub(1, Ordering::SeqCst); -} - impl Glob { // R-2 (host-fn re-entrancy): all JS-exposed methods take `&self`. `Glob`'s // fields are read-only after construction (`pattern`) or already atomic @@ -436,18 +432,21 @@ impl Glob { Ok(Some(gw)) => gw, }; - incr_pending_activity_flag(&self.has_pending_activity); - let mut task = WalkTask::create(global_this, glob_walker, &self.has_pending_activity); - let promise = task.promise.value(); - task.schedule(); - // Ownership passes to the work pool / event loop; freed via - // `ConcurrentPromiseTask::destroy` on the `.manual_deinit` path. - // WalkTask<'_> borrows `&self.has_pending_activity` - // and `global_this`. Both referents outlive the task: `Glob` is GC-rooted - // via `hasPendingActivity()`, and `JSGlobalObject` lives until VM teardown. - // `into_raw` erases the stack-tied `'_` once the heap allocation escapes. - let _ = bun_core::heap::into_raw(task); - Ok(promise) + let cx = global_this.js_thread(); + let promise = JSPromiseStrong::init(global_this); + let value = promise.value(); + Job::::schedule( + &cx, + WalkTask { + walker: glob_walker, + err: None, + }, + WalkJs { + promise, + _pending: PendingScan::new(&self.has_pending_activity), + }, + ); + Ok(value) } #[bun_jsc::host_fn(method)] diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index dfb79bb5c37e..27657f0da198 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -24,7 +24,6 @@ use bun_core::String as BunString; use bun_core::env::OperatingSystem; use bun_io::KeepAlive; use bun_jsc::WorkPool; -use bun_jsc::event_loop::EventLoop; use bun_jsc::{self as jsc, JSGlobalObject, JSPromise, JSValue}; use bun_options_types::WindowsOptions; use bun_options_types::schema::api; @@ -55,15 +54,25 @@ pub struct JSBundleCompletionTask { // `unsafe impl Send` below for the thread-affinity constraint this imposes. pub(crate) ref_count: RefCount, pub(crate) config: JSBundlerConfig, - // BACKREF — the JS-thread `EventLoop` outlives every completion task; safe - // `Deref` so call sites read `self.jsc_event_loop.enqueue_task_concurrent(..)`. - pub(crate) jsc_event_loop: BackRef, + /// How the bundle thread (and plugin hops) reach the VM that called Bun.build. + pub(crate) loop_handle: jsc::LoopHandle, pub global_this: BackRef, pub(crate) promise: jsc::JSPromiseStrong, pub poll_ref: KeepAlive, pub(crate) env: *mut bun_dotenv::Loader, pub(crate) log: bun_ast::Log, - pub(crate) cancelled: bool, + /// Set by the owner giving up on the result (HTMLBundle route torn down) + /// or by the VM's stop phase; read by `on_complete` (skip delivery) and by + /// the bundle thread (`CompletionDispatch::is_cancelled`: stop waiting on + /// plugins, fail the build). + pub(crate) cancelled: core::sync::atomic::AtomicBool, + /// The bundle thread's uws loop while this build runs there, so a + /// cancelling VM can wake its Mini loop out of an idle wait. + pub(crate) bundle_loop: core::sync::atomic::AtomicPtr, + /// [`Stage`]: whether the (single, process-wide) bundle thread has taken + /// this build off its queue yet. A VM tearing down releases a build that + /// is still queued itself instead of waiting behind other VMs' builds. + pub(crate) stage: core::sync::atomic::AtomicU8, pub(crate) html_build_task: Option<*mut html_bundle::Route>, @@ -77,6 +86,21 @@ pub struct JSBundleCompletionTask { pub(crate) started_at_ns: u64, } +#[repr(u8)] +pub(crate) enum Stage { + /// On the bundle thread's queue; nothing there has touched it. + Queued = 0, + /// The bundle thread is (or was) running it. + Started = 1, + /// Its VM is tearing down first and is releasing the JS side right now; + /// the bundle thread, if it dequeues it meanwhile, waits for + /// `ReleasedUnstarted` before freeing. + Releasing = 2, + /// The JS side is released and the count returned; the bundle thread + /// frees the rest when it dequeues it. + ReleasedUnstarted = 3, +} + impl JSBundleCompletionTask { /// `RefCounted` destructor — last ref dropped. /// @@ -86,7 +110,11 @@ impl JSBundleCompletionTask { // SAFETY: refcount hit zero; `this` is the sole owner of a // `heap::alloc`'d allocation. let mut boxed = unsafe { bun_core::heap::take(this) }; - boxed.poll_ref.disable(); + // Already `Done` (and this may be the bundle thread) for a build + // released unstarted; see `stop_for_vm_teardown`. + if boxed.poll_ref.is_active() { + boxed.poll_ref.disable(); + } if let Some(plugin) = boxed.plugins.take() { // `plugin` is the live FFI handle stashed at construction; // last-ref drop is the only place that releases it. @@ -111,22 +139,21 @@ pub(crate) fn create_and_schedule_completion_task( config: JSBundlerConfig, plugins: Option>, global_this: &JSGlobalObject, - event_loop: *mut EventLoop, ) -> crate::Result<*mut JSBundleCompletionTask> { let vm = global_this.bun_vm_ptr(); let env = global_this.bun_vm().transpiler.env; let completion = bun_core::heap::into_raw(Box::new(JSBundleCompletionTask { ref_count: RefCount::init(), config, - // `event_loop` is the live JS-thread loop (caller derives it from - // `vm.event_loop()`); never null once `Bun.build` is reachable. - jsc_event_loop: BackRef::from(core::ptr::NonNull::new(event_loop).expect("event_loop")), + loop_handle: global_this.bun_vm().loop_handle(), global_this: BackRef::new(global_this), promise: jsc::JSPromiseStrong::default(), poll_ref: KeepAlive::init(), env, log: bun_ast::Log::init(), - cancelled: false, + cancelled: core::sync::atomic::AtomicBool::new(false), + bundle_loop: core::sync::atomic::AtomicPtr::new(ptr::null_mut()), + stage: core::sync::atomic::AtomicU8::new(Stage::Queued as u8), html_build_task: None, result: BundleV2Result::Pending, next: bun_threading::Link::new(), @@ -145,6 +172,13 @@ pub(crate) fn create_and_schedule_completion_task( // conditions from creating two let _ = WorkPool::get(); + // Out on the bundle thread from here until it posts the completion: it + // reads this VM's env loader and the plugin cell, so the VM cancels it at + // teardown (registry) and waits for it (embedded work). + // SAFETY: `completion` is live (refcount==1), JS thread. + unsafe { (*completion).loop_handle.embedded_work_scheduled() }; + crate::jsc_hooks::ActiveHandle::Bundle(NonNull::new(completion).expect("completion")) + .register(); bun_bundler::bundle_v2::singleton::enqueue::(completion); // SAFETY: `completion` is live (refcount==1); `vm` outlives this call. @@ -526,6 +560,7 @@ impl JSBundleCompletionTask { } pub(crate) fn on_complete_anytask(ctx: *mut Self) -> bun_event_loop::JsResult<()> { + crate::jsc_hooks::ActiveHandle::Bundle(NonNull::new(ctx).expect("completion")).unregister(); // For the +1 taken by `complete_on_bundle_thread` enqueue. // SAFETY: `ctx` is the live heap allocation; `adopt` consumes the prior +1 on Drop. let _drop_ref = unsafe { bun_ptr::ScopedRef::::adopt(ctx) }; @@ -537,13 +572,65 @@ impl JSBundleCompletionTask { unsafe { &mut *ctx }.on_complete() } + /// VM teardown's stop phase (JS thread): give up on the result. + /// + /// * Still queued behind other builds: release the JS side here (plugin + /// cell, promise, keep-alive), return the count, and leave the inert rest + /// for the bundle thread to free when it dequeues it — the VM does not + /// wait behind other VMs' builds. + /// * Already on the bundle thread: tombstone the plugin, cancel and wake it; + /// it fails what the plugins hold, fails the build and posts the + /// completion, which teardown waits for and releases. + /// + /// # Safety + /// `this` is live (registered ⇒ its completion has not run); JS thread. + pub(crate) unsafe fn stop_for_vm_teardown(this: *mut Self) { + use core::sync::atomic::Ordering; + // SAFETY: fn contract; the plugin cell is protected by this task; the + // loop pointer is a thread's uws loop, valid for that thread's + // lifetime, and wakeup is thread-safe. + unsafe { + if (*this) + .stage + .compare_exchange( + Stage::Queued as u8, + Stage::Releasing as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + (*this).poll_ref.disable(); + if let Some(plugin) = (*this).plugins.take() { + Plugin::destroy(plugin.as_ptr()); + } + (*this).promise = jsc::JSPromiseStrong::default(); + let handle = (*this).loop_handle.clone(); + // Publish only now: from here the bundle thread may free `this`. + (*this) + .stage + .store(Stage::ReleasedUnstarted as u8, Ordering::Release); + handle.embedded_work_finished(); + return; + } + if let Some(plugins) = (*this).plugins { + crate::api::JSBundler::PluginJscExt::tombstone(plugins.as_ref()); + } + (*this).cancelled.store(true, Ordering::Release); + let l = (*this).bundle_loop.load(Ordering::Acquire); + if !l.is_null() { + bun_uws::us_wakeup_loop(l); + } + } + } + fn on_complete(&mut self) -> bun_event_loop::JsResult<()> { let this = self; let vm = this.global_this.bun_vm_ptr(); // SAFETY: `vm` is the live per-thread VM (`global_this.bun_vm_ptr()`). this.poll_ref .unref(unsafe { jsc::virtual_machine::VirtualMachine::event_loop_ctx(vm) }); - if this.cancelled { + if this.cancelled.load(core::sync::atomic::Ordering::Acquire) { return Ok(()); } @@ -769,14 +856,21 @@ fn from_completion_handle<'a>(c: NonNull) -> &'a JSBundleCo static COMPLETION_VTABLE: dispatch::CompletionDispatch = dispatch::CompletionDispatch { result_is_err: |c| matches!(from_completion_handle(c).result, BundleV2Result::Err(_)), + is_cancelled: |c| { + from_completion_handle(c) + .cancelled + .load(core::sync::atomic::Ordering::Acquire) + }, enqueue_task_concurrent: |c, task| { - // `jsc_event_loop` is a `BackRef` — safe Deref. - // SAFETY: `task` is a fresh heap-allocated non-null `ConcurrentTaskItem` - // passed through from the bundler vtable; the queue takes ownership. + // SAFETY: `task` is a fresh non-null `ConcurrentTaskItem` passed through + // from the bundler vtable; the queue takes ownership. The VM waits for + // this build (embedded work) before closing its handle: always queued. unsafe { - from_completion_handle(c) - .jsc_event_loop - .enqueue_task_concurrent(core::ptr::NonNull::new_unchecked(task)) + let task = core::ptr::NonNull::new_unchecked(task); + let c = from_completion_handle(c); + let jsc::vm_handle::Posted::Queued = c.loop_handle.post_task(task) else { + unreachable!("VM handle closed with a Bun.build outstanding"); + }; } }, }; @@ -793,6 +887,35 @@ unsafe impl bun_threading::Linked for JSBundleCompletionTask { } impl CompletionStruct for JSBundleCompletionTask { + fn try_start(&mut self) -> bool { + use core::sync::atomic::Ordering; + self.stage + .compare_exchange( + Stage::Queued as u8, + Stage::Started as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + #[allow(clippy::not_unsafe_ptr_arg_deref)] // trait contract: dequeued ⇒ sole owner + fn free_released_unstarted(this: *mut Self) { + use core::sync::atomic::Ordering; + // `try_start` lost to the VM's teardown, which may still be releasing + // the JS side (`Releasing`): a handful of stores on the JS thread. + // SAFETY: dequeued and not started ⇒ live until we free it below. + while unsafe { (*this).stage.load(Ordering::Acquire) } != Stage::ReleasedUnstarted as u8 { + core::hint::spin_loop(); + } + // The VM released everything thread-affine (`stop_for_vm_teardown`); + // what is left — config, log, an empty promise slot, a `Done` + // keep-alive, the handle clone — is ours to drop here. The queue held + // the creation reference. + // SAFETY: dequeued ⇒ sole owner; nothing JS-affine remains. + drop(unsafe { bun_core::heap::take(this) }); + } + /// Port of `JSBundleCompletionTask.configureBundler` — the post-init half /// (everything after `transpiler.* = try Transpiler.init(...)`). /// `Transpiler::init` itself is called by `create_and_configure_transpiler` @@ -992,12 +1115,17 @@ impl CompletionStruct for JSBundleCompletionTask { } fn complete_on_bundle_thread(&mut self) { - // `jsc_event_loop` is a `BackRef` — safe Deref. - // `ConcurrentTask::create` heap-allocates a fresh task; the - // queue takes ownership of it. + // The bundle thread's last touch of this task and of the VM's memory: + // hand it back (always queued — the VM waits for it) and stop counting. + self.bundle_loop + .store(ptr::null_mut(), core::sync::atomic::Ordering::Release); + let handle = self.loop_handle.clone(); let this = std::ptr::from_mut::(self); - self.jsc_event_loop - .enqueue_task_concurrent(jsc::ConcurrentTask::create(jsc::Task::init(this))); + let ct = jsc::ConcurrentTask::create(jsc::Task::init(this)); + let jsc::vm_handle::Posted::Queued = handle.post_task(ct) else { + unreachable!("VM handle closed with a Bun.build outstanding"); + }; + handle.embedded_work_finished(); } fn set_result(&mut self, result: BundleV2Result) { self.result = result; @@ -1091,6 +1219,11 @@ impl CompletionStruct for JSBundleCompletionTask { let mut any_loop = bun_event_loop::AnyEventLoop::default(); let event_loop: bun_bundler::linker_context_mod::EventLoop = Some(NonNull::from(&mut any_loop).cast::()); + if let bun_event_loop::AnyEventLoop::Mini(mini) = &any_loop { + // So a cancelling VM can wake us out of an idle wait for plugins. + self.bundle_loop + .store(mini.loop_ptr(), core::sync::atomic::Ordering::Release); + } // `thread_pool` is the `WorkPool` singleton (`OnceLock`-backed, // process-lifetime, concurrently read by worker threads). Do NOT @@ -1142,4 +1275,10 @@ impl CompletionStruct for JSBundleCompletionTask { impl bun_event_loop::Taskable for JSBundleCompletionTask { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::JSBundleCompletionTask; + /// A Bun.build the bundle thread handed back during teardown (cancelled in + /// the stop phase): its completion releases the keep-alive, plugin cell + /// and promise against the live heap. + unsafe fn release_unrun(this: *mut Self) { + let _ = JSBundleCompletionTask::on_complete_anytask(this); + } } diff --git a/src/runtime/bake/BakeGlobalObject.cpp b/src/runtime/bake/BakeGlobalObject.cpp index 90e3b4e311f1..766b100614fe 100644 --- a/src/runtime/bake/BakeGlobalObject.cpp +++ b/src/runtime/bake/BakeGlobalObject.cpp @@ -248,7 +248,7 @@ extern "C" GlobalObject* BakeCreateProdGlobal(void* console) vm.heap.acquireAccess(); JSC::JSLockHolder locker(vm); BunVirtualMachine* bunVM = Bun__getVM(); - WebCore::JSVMClientData::create(&vm, bunVM); + WebCore::JSVMClientData::create(&vm, bunVM, /* isWorkerVM */ false); JSC::Structure* structure = Bake::GlobalObject::createStructure(vm); Bake::GlobalObject* global = Bake::GlobalObject::create( diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index 0cb24cc21960..3ca367b6f914 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -304,6 +304,8 @@ pub struct DevServer { /// is the JSC_BORROW guarantee: vm is valid for DevServer's entire /// lifetime. pub(crate) vm: bun_ptr::BackRef, + /// How the file-watcher thread submits hot-reload events to the VM. + pub(crate) vm_handle: bun_jsc::VmHandle, /// May be `None` if not attached to an HTTP server yet. When no server is /// available, functions taking in requests and responses are unavailable. /// However, a lot of testing in this mode is missing, so it may hit assertions. @@ -515,6 +517,7 @@ pub(crate) fn init(options: Options) -> JsResult> { w!(magic, Magic::Valid); w!(root, Box::from(options.root.as_bytes())); w!(vm, bun_ptr::BackRef::new(options.vm)); + w!(vm_handle, options.vm.handle()); w!(server, None); w!(directory_watchers, DirectoryWatchStore::default()); w!(server_fetch_function_callback, jsc::StrongOptional::empty()); @@ -1020,6 +1023,7 @@ impl Drop for DevServer { inspector_server_id: _, configuration_hash_key: _, vm: _, + vm_handle: _, server: _, router: _, route_bundles: _, diff --git a/src/runtime/bake/bake_body.rs b/src/runtime/bake/bake_body.rs index cdd8ab2dadcd..ded4de92aea2 100644 --- a/src/runtime/bake/bake_body.rs +++ b/src/runtime/bake/bake_body.rs @@ -372,9 +372,9 @@ impl SplitBundlerOptions { // be resolved before the first bundle task can begin. // SAFETY: `bun_vm()` returns a non-null `*mut VirtualMachineRef` // live for the lifetime of the global object. - global.bun_vm().as_mut().wait_for_promise(promise); + global.bun_vm().as_mut().wait_for_promise(promise)?; match promise.unwrap(global.vm(), bun_jsc::PromiseUnwrapMode::MarkHandled) { - bun_jsc::PromiseResult::Pending => unreachable!(), + bun_jsc::PromiseResult::Pending => unreachable!("wait_for_promise returned Ok"), bun_jsc::PromiseResult::Fulfilled(_val) => {} bun_jsc::PromiseResult::Rejected(err) => { return Err(global.throw_value(err)); diff --git a/src/runtime/bake/dev_server/memory_cost.rs b/src/runtime/bake/dev_server/memory_cost.rs index 2a4c014a4e20..766c627921cd 100644 --- a/src/runtime/bake/dev_server/memory_cost.rs +++ b/src/runtime/bake/dev_server/memory_cost.rs @@ -44,6 +44,7 @@ pub(crate) fn memory_cost_detailed(dev: &DevServer) -> MemoryCost { inspector_server_id: _, configuration_hash_key: _, vm: _, + vm_handle: _, server: _, router: _, route_bundles: _, diff --git a/src/runtime/bake/dev_server/mod.rs b/src/runtime/bake/dev_server/mod.rs index b4d0b3c5a672..211753667525 100644 --- a/src/runtime/bake/dev_server/mod.rs +++ b/src/runtime/bake/dev_server/mod.rs @@ -394,6 +394,18 @@ pub struct HotReloadEvent { impl bun_event_loop::Taskable for HotReloadEvent { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::BakeHotReloadEvent; + /// An inline slot of the watcher's `WatcherAtomics`. If its DevServer is + /// gone (`owner` nulled by `Drop for DevServer`, which then leaves the + /// atomics to the queued event), this was the last thing keeping the + /// atomics alive; otherwise the DevServer still owns them. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract; `atomics` is the heap WatcherAtomics `this` lives in. + unsafe { + if (*this).owner.is_null() { + bun_core::heap::destroy((*this).atomics); + } + } + } } impl HotReloadEvent { @@ -964,11 +976,12 @@ impl WatcherAtomics { task: bun_event_loop::Task::init(ev), ..Default::default() }; - // `vm` is a `BackRef` (safe Deref); `event_loop` points at a - // sibling field of `VirtualMachine`. The queued node pointer - // is derived from `ev` (allocation-root provenance) so it - // stays valid across `Drop for DevServer`'s writes. - (*(&(*(*ev).owner).vm).event_loop).enqueue_task_concurrent( + // The queued node pointer is derived from `ev` (allocation-root + // provenance) so it stays valid across `Drop for DevServer`'s + // writes. Refused ⇒ the VM is torn down; the event is one of + // DevServer's inline slots and simply never runs. + let _ = (*(*ev).owner).vm_handle.post( + bun_jsc::LoopKind::Regular, core::ptr::NonNull::new_unchecked(&raw mut (*ev).concurrent_task), ); } diff --git a/src/runtime/bake/production.rs b/src/runtime/bake/production.rs index dc586fbe917c..95aa242fdfa6 100644 --- a/src/runtime/bake/production.rs +++ b/src/runtime/bake/production.rs @@ -118,8 +118,12 @@ pub fn build_command(ctx: Context) -> crate::Result<()> { // edition-2024 disjoint-capture rules collides with the `&mut *vm_ptr` // re-borrows on the JSError path). let _vm_guard = scopeguard::guard(vm_ptr, |p| { - // SAFETY: p is the unique live VM on this thread. - unsafe { (*p).destroy() }; + // SAFETY: p is the unique live VM on this thread; its loop is alive, so + // queued work is released here rather than by a thread teardown. + unsafe { + (*p).release_queued_work(); + (*p).destroy() + }; }); // A special global object is used to allow registering virtual modules @@ -342,7 +346,8 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result< // `opaque_mut` is the const-asserted safe `*mut → &mut` accessor // (`load_and_evaluate_module_ptr` returned a live JSC-heap cell). jsc::JSInternalPromise::opaque_mut(config_promise_ptr).set_handled(); - vm.wait_for_promise(AnyPromise::Internal(config_promise_ptr)); + vm.wait_for_promise(AnyPromise::Internal(config_promise_ptr)) + .map_err(|_| js_err(jsc::JsError::Terminated))?; let jsc_vm = vm.jsc_vm_mut(); // Promise cell is still live (rooted via the module loader). let mut options = match jsc::JSInternalPromise::opaque_mut(config_promise_ptr) @@ -1196,7 +1201,8 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result< // above accessed the same allocation through `vm_ptr`, invalidating the // earlier `&mut` under Stacked Borrows. let vm = VirtualMachine::get().as_mut(); - vm.wait_for_promise(AnyPromise::Normal(render_promise)); + vm.wait_for_promise(AnyPromise::Normal(render_promise)) + .map_err(|_| js_err(jsc::JsError::Terminated))?; let jsc_vm = vm.jsc_vm_mut(); match render_promise.unwrap(jsc_vm, UnwrapMode::MarkHandled) { Unwrapped::Pending => unreachable!(), @@ -1235,7 +1241,8 @@ fn load_module( let vm_ref = VirtualMachine::get(); vm_ref .as_mut() - .wait_for_promise(AnyPromise::Internal(promise)); + .wait_for_promise(AnyPromise::Internal(promise)) + .map_err(|_| js_err(jsc::JsError::Terminated))?; // TODO: Specially draining microtasks here because `waitForPromise` has a // bug which forgets to do it, but I don't want to fix it right now as it // could affect a lot of the codebase. This should be removed. diff --git a/src/runtime/cli/filter_run.rs b/src/runtime/cli/filter_run.rs index 4befcd423fd0..7cfd582d26ac 100644 --- a/src/runtime/cli/filter_run.rs +++ b/src/runtime/cli/filter_run.rs @@ -148,10 +148,10 @@ impl<'a> ProcessHandle<'a> { #[cfg(windows)] { if let spawn::WindowsStdioResult::Buffer(pipe) = stdout_pipe { - handle.stdout.source = Some(bun_io::Source::Pipe(pipe)); + handle.stdout.set_source(bun_io::Source::Pipe(pipe)); } if let spawn::WindowsStdioResult::Buffer(pipe) = stderr_pipe { - handle.stderr.source = Some(bun_io::Source::Pipe(pipe)); + handle.stderr.set_source(bun_io::Source::Pipe(pipe)); } } diff --git a/src/runtime/cli/multi_run.rs b/src/runtime/cli/multi_run.rs index bd97c1b2aa6e..99e6faefc5f7 100644 --- a/src/runtime/cli/multi_run.rs +++ b/src/runtime/cli/multi_run.rs @@ -196,10 +196,14 @@ impl<'a> ProcessHandle<'a> { // the Box out of the spawn *result* — `WindowsStdioResult::take()` // leaves `Unavailable` behind so `spawned`'s drop is a no-op. if let spawn::WindowsStdioResult::Buffer(pipe) = spawned.stdout.take() { - self.stdout_reader.reader.source = Some(bun_io::Source::Pipe(pipe)); + self.stdout_reader + .reader + .set_source(bun_io::Source::Pipe(pipe)); } if let spawn::WindowsStdioResult::Buffer(pipe) = spawned.stderr.take() { - self.stderr_reader.reader.source = Some(bun_io::Source::Pipe(pipe)); + self.stderr_reader + .reader + .set_source(bun_io::Source::Pipe(pipe)); } } diff --git a/src/runtime/cli/repl.rs b/src/runtime/cli/repl.rs index 1c5ca4732d00..553d90cf2ba1 100644 --- a/src/runtime/cli/repl.rs +++ b/src/runtime/cli/repl.rs @@ -1335,7 +1335,9 @@ impl<'a> Repl<'a> { // Note: reshaped for borrowck — call disable_signals_during_wait() explicitly on each return path below // Wait for the promise to settle - vm.as_mut() + // Interrupted (SIGINT forbids execution) ⇒ handled just below. + let _ = vm + .as_mut() .wait_for_promise(jsc::AnyPromise::Normal(promise)); // If execution was forbidden by SIGINT, clear it and report @@ -1496,7 +1498,9 @@ impl<'a> Repl<'a> { // SAFETY: `promise` is a live JSC heap cell; `vm.jsc_vm` is the // owning JSC VM handle for this thread. jsc::JSPromise::opaque_mut(promise).set_handled(); - vm.as_mut() + // Interrupted (SIGINT forbids execution) ⇒ handled just below. + let _ = vm + .as_mut() .wait_for_promise(jsc::AnyPromise::Normal(promise)); let jsc_vm_ref = vm.jsc_vm(); match jsc::JSPromise::opaque_mut(promise).status() { @@ -1636,7 +1640,9 @@ impl<'a> Repl<'a> { jsc::JSPromise::opaque_mut(promise).set_handled(); self.enable_signals_during_wait(); // Note: reshaped for borrowck — disable_signals_during_wait called on each path - vm.as_mut() + // Interrupted (SIGINT forbids execution) ⇒ handled just below. + let _ = vm + .as_mut() .wait_for_promise(jsc::AnyPromise::Normal(promise)); if vm.jsc_vm().execution_forbidden() { vm.jsc_vm().set_execution_forbidden(false); diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index 6e1efa7a63c6..a24c9e6e6567 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -571,7 +571,7 @@ impl<'a> WorkerLoop<'a> { test_command::handle_top_level_test_error_before_javascript_start(&err); } if vm.test_isolation_enabled { - crate::jsc_hooks::close_isolation_handles(vm); + crate::jsc_hooks::stop_active_handles_for_test_isolation(vm); vm.swap_global_for_test_isolation(); self.reporter .jest diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index be81fc85e8b6..a682e399fa12 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -2347,7 +2347,7 @@ impl TestCommand { vm.transpiler.options.minify_identifiers = false; vm.transpiler.options.minify_whitespace = false; vm.transpiler.options.dead_code_elimination = false; - vm.global().vm().set_control_flow_profiler(true); + vm.global().vm().enable_control_flow_profiler(); } // For tests, we default to UTC time zone @@ -3145,7 +3145,7 @@ impl TestCommand { reporter.jest.default_timeout_override = u32::MAX; Global::mimalloc_cleanup(false); if isolate { - crate::jsc_hooks::close_isolation_handles(vm); + crate::jsc_hooks::stop_active_handles_for_test_isolation(vm); vm.swap_global_for_test_isolation(); reporter .jest diff --git a/src/runtime/crypto/PBKDF2.rs b/src/runtime/crypto/PBKDF2.rs index fa2468df7bd8..66e94459374f 100644 --- a/src/runtime/crypto/PBKDF2.rs +++ b/src/runtime/crypto/PBKDF2.rs @@ -2,8 +2,8 @@ use core::ffi::c_uint; use bun_boringssl_sys as boringssl; use bun_jsc::{ - AnyTaskJob, AnyTaskJobCtx, ArrayBuffer, CallFrame, JSGlobalObject, JSPromiseStrong, JSValue, - JsResult, + ArrayBuffer, CallFrame, JSGlobalObject, JSPromiseStrong, JSValue, Job, JobContext, JsResult, + JsThread, }; use crate::node::StringOrBuffer; @@ -257,72 +257,74 @@ impl bun_jsc::Unprotect for PBKDF2 { } } -pub(crate) struct Pbkdf2Ctx { - /// Wrapped in [`bun_jsc::ThreadSafe`] so the paired `unprotect()` runs on - /// drop — `Job` is only constructed on the async path - /// (`from_js(.., is_async=true)` already protected the buffers). +/// `crypto.pbkdf2` off the JS thread. +pub(crate) struct Pbkdf2Job { + /// `from_js(.., is_async=true)` protected the input buffers; the + /// [`bun_jsc::ThreadSafe`] releases that with the job. pub pbkdf2: bun_jsc::ThreadSafe, pub output: Vec, pub err: bool, - pub promise: JSPromiseStrong, } -impl AnyTaskJobCtx for Pbkdf2Ctx { - fn run(&mut self, _global: *mut JSGlobalObject) { - let len = usize::try_from(self.pbkdf2.length).expect("int cast"); +impl JobContext for Pbkdf2Job { + type OffThread = Self; + type Js = JSPromiseStrong; + + fn run( + this: &mut Self, + _vm: &bun_jsc::vm_handle::Borrow, + done: bun_jsc::Completion, + ) -> Option> { + let len = usize::try_from(this.pbkdf2.length).expect("int cast"); // `Vec` allocation aborts on OOM; use try_reserve to surface an error instead. let mut buf = Vec::new(); if buf.try_reserve_exact(len).is_err() { - self.err = true; - return; + this.err = true; + return Some(done); } buf.resize(len, 0); - self.output = buf; + this.output = buf; - if !self.pbkdf2.run(&mut self.output) { - self.err = true; + if !this.pbkdf2.run(&mut this.output) { + this.err = true; boringssl::ERR_clear_error(); - - self.output = Vec::new(); + this.output = Vec::new(); } + Some(done) } - fn then(&mut self, global_this: &JSGlobalObject) -> JsResult<()> { - let promise = self.promise.swap(); - if self.err { + fn then(mut this: Self, mut promise: JSPromiseStrong, cx: &JsThread<'_>) -> JsResult<()> { + let global_this = cx.global(); + let promise = promise.swap(); + if this.err { let err = global_this.create_error_instance(format_args!("PBKDF2 derivation failed")); promise.reject_with_async_stack(global_this, Ok(err))?; return Ok(()); } - let output_slice = core::mem::take(&mut self.output); - debug_assert!(output_slice.len() == usize::try_from(self.pbkdf2.length).expect("int cast")); + let output_slice = core::mem::take(&mut this.output); + debug_assert!(output_slice.len() == usize::try_from(this.pbkdf2.length).expect("int cast")); // Ownership transfers to JSC (freed via MarkedArrayBuffer_deallocator → mimalloc free). let buffer_value = JSValue::create_buffer(global_this, output_slice.leak()); - promise.resolve(global_this, buffer_value)?; + promise.settle(global_this, buffer_value)?; Ok(()) } } -pub(crate) type Job = AnyTaskJob; - -/// Heap-allocate, init the promise, ref the loop, and hand -/// to the work pool. Returns the live job so the caller can read -/// `(*job).ctx.promise.value()` before the JS-thread completion fires. -/// Free fn (not `impl Job`) because `AnyTaskJob<_>` is a foreign type. -pub(crate) fn create_job(global_this: &JSGlobalObject, data: PBKDF2) -> *mut Job { - let job = AnyTaskJob::create( - global_this, - Pbkdf2Ctx { +/// Schedule the derivation on the work pool; returns its promise. +pub(crate) fn create_job(global_this: &JSGlobalObject, data: PBKDF2) -> JSValue { + let cx = global_this.js_thread(); + let promise = JSPromiseStrong::init(global_this); + let value = promise.value(); + Job::::schedule( + &cx, + Pbkdf2Job { // `from_js(.., is_async=true)` already protected — adopt, don't re-protect. pbkdf2: bun_jsc::ThreadSafe::adopt(data), output: Vec::new(), err: false, - promise: JSPromiseStrong::init(global_this), }, - ) - .expect("Pbkdf2Ctx::init is infallible"); - // SAFETY: `job` is a freshly-created live pointer. - unsafe { AnyTaskJob::schedule(job) }; - job + promise, + ); + value } diff --git a/src/runtime/crypto/PasswordObject.rs b/src/runtime/crypto/PasswordObject.rs index de5c596a52b5..2b2bb24c255b 100644 --- a/src/runtime/crypto/PasswordObject.rs +++ b/src/runtime/crypto/PasswordObject.rs @@ -3,19 +3,12 @@ use core::fmt::Write as _; use std::io::Write as _; use bun_core::ZigString; -use bun_io::KeepAlive; -use bun_jsc::event_loop::EventLoop; -use bun_jsc::{ - self as jsc, ArrayBuffer, CallFrame, JSFunction, JSGlobalObject, JSValue, JsError, JsResult, - WorkPoolTask, -}; +use bun_jsc::{ArrayBuffer, CallFrame, JSFunction, JSGlobalObject, JSValue, JsError, JsResult}; // JSC-side ZigString carries `to_js` (the `bun_core::ZigString` repr-twin // lives in `bun_jsc::zig_string`); used for ASCII→JS conversions only. -use bun_jsc::ConcurrentTask::ConcurrentTask; use bun_jsc::ZigStringJsc as _; use bun_jsc::zig_string::ZigString as JscZigString; use bun_jsc::{JSPromise, JSPromiseStrong}; -use bun_threading::work_pool::WorkPool; use crate::node::StringOrBuffer; @@ -485,12 +478,11 @@ extern "C" fn JSPasswordObject__create(global_object: &JSGlobalObject) -> JSValu // both into one `PasswordJob` / `PasswordResult` parameterised on a // `PasswordOp` carrying exactly those three axes. -pub(crate) trait PasswordOp: 'static { +pub(crate) trait PasswordOp: Send + 'static { /// Success payload (`Box<[u8]>` for hash, `bool` for verify). - type Value; + type Value: Send; /// "hashing" | "verification" — slotted into the JS Error message. const ERR_VERB: &'static str; - const TASK_TAG: bun_event_loop::TaskTag; /// Off-thread compute. `self` borrows the op so its inputs stay owned by /// the job and are `free_sensitive`d in the job's / op's `Drop`. fn compute(&self, password: &[u8]) -> Result; @@ -504,7 +496,6 @@ pub(crate) struct HashOp { impl PasswordOp for HashOp { type Value = Box<[u8]>; const ERR_VERB: &'static str = "hashing"; - const TASK_TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::PasswordHashResult; fn compute(&self, password: &[u8]) -> Result, HashError> { PasswordObject::hash(password, self.algorithm) } @@ -528,7 +519,6 @@ impl Drop for VerifyOp { impl PasswordOp for VerifyOp { type Value = bool; const ERR_VERB: &'static str = "verification"; - const TASK_TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::PasswordVerifyResult; fn compute(&self, password: &[u8]) -> Result { PasswordObject::verify(password, &self.prev_hash, self.algorithm) } @@ -557,19 +547,16 @@ fn password_error_instance(err: &HashError, verb: &str, g: &JSGlobalObject) -> J instance } +/// `Bun.password.hash/verify` off the JS thread: the op and the password are +/// owned copies (zeroed on drop); the promise is the JS side. struct PasswordJob { op: Op, password: Box<[u8]>, - promise: JSPromiseStrong, - event_loop: *mut EventLoop, - global: *const JSGlobalObject, - r#ref: KeepAlive, - task: WorkPoolTask, + value: Option>, } impl Drop for PasswordJob { fn drop(&mut self) { - // promise: Drop on JSPromiseStrong handles deinit. // bun.freeSensitive — volatile-zero the buffer then free; take the Box so // the field's own Drop sees an empty slice afterwards. Any op-owned // sensitive buffers (`prev_hash`) are freed by the op's own `Drop`. @@ -577,58 +564,24 @@ impl Drop for PasswordJob { } } -bun_threading::owned_task!([Op: PasswordOp] PasswordJob, task); - -impl PasswordJob { - // `owned_task!` requires `fn run_owned(self: Box)`; clippy::boxed_local - // is a false positive on this macro contract. - #[allow(clippy::boxed_local)] - fn run_owned(mut self: Box) { - let value = self.op.compute(&self.password); - let result = Box::new(PasswordResult:: { - value, - promise: core::mem::take(&mut self.promise), - global: self.global, - r#ref: core::mem::take(&mut self.r#ref), - }); - // SAFETY: `event_loop` was stored from the JS-thread VM and outlives the - // job; ownership of `result` transfers to the event loop here. - unsafe { - (*self.event_loop).enqueue_task_concurrent(ConcurrentTask::create( - bun_event_loop::Task::from_boxed(result), - )); - } - // `self: Box` drops here; Drop runs secure_zero on password (+op). - } -} - -pub(crate) struct PasswordResult { - value: Result, - r#ref: KeepAlive, - promise: JSPromiseStrong, - global: *const JSGlobalObject, -} - -impl bun_event_loop::Taskable for PasswordResult { - const TAG: bun_event_loop::TaskTag = Op::TASK_TAG; -} - -impl PasswordResult { - pub(crate) fn run_from_js(this: *mut Self) -> Result<(), jsc::JsTerminated> { - // SAFETY: `this` was produced by heap::into_raw in `run_owned` and the - // event loop hands sole ownership to this callback. Reclaim the Box once - // up-front so all fields drop on scope exit (no `mem::replace` dance). - let this = *unsafe { bun_core::heap::take(this) }; - let PasswordResult { - value, - mut r#ref, - mut promise, - global, - } = this; - // SAFETY: `global` stored from a live `&JSGlobalObject`; VM outlives the task. - let global = unsafe { &*global }; - r#ref.unref(bun_io::js_vm_ctx()); - match value { +impl bun_jsc::JobContext for PasswordJob { + type OffThread = Self; + type Js = JSPromiseStrong; + fn run( + this: &mut Self, + _vm: &bun_jsc::vm_handle::Borrow, + done: bun_jsc::Completion, + ) -> Option> { + this.value = Some(this.op.compute(&this.password)); + Some(done) + } + fn then( + mut this: Self, + mut promise: JSPromiseStrong, + cx: &bun_jsc::JsThread<'_>, + ) -> JsResult<()> { + let global = cx.global(); + match this.value.take().expect("computed") { Err(err) => { let error_instance = password_error_instance(&err, Op::ERR_VERB, global); promise.reject_with_async_stack(global, Ok(error_instance))?; @@ -667,20 +620,15 @@ impl JSPasswordObject { let promise = JSPromiseStrong::init(global_object); let promise_value = promise.value(); - - let mut job = Box::new(PasswordJob:: { - op, - password, + bun_jsc::Job::>::schedule( + &global_object.js_thread(), + PasswordJob { + op, + password, + value: None, + }, promise, - // SAFETY: bun_vm() is non-null for a Bun-owned global; VM outlives the job. - event_loop: global_object.bun_vm().event_loop(), - global: std::ptr::from_ref(global_object), - r#ref: KeepAlive::default(), - task: WorkPoolTask::default(), - }); - job.r#ref.ref_(bun_io::js_vm_ctx()); - WorkPool::schedule_owned(job); - + ); Ok(promise_value) } diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index 654b15564441..df0996b0438a 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -13,10 +13,11 @@ //! `#[no_mangle]` so the linker resolves the call directly — no runtime //! registration, no `AtomicPtr`, no init-order hazard. //! -//! **Adding a variant** (do all three): +//! **Adding a variant** (do all four): //! 1. tag constant in `bun_event_loop::task_tag` (or `bun_io::poll_tag`); -//! 2. `impl bun_jsc::Taskable for YourType { const TAG = task_tag::YourType; }`; -//! 3. a match arm here. +//! 2. `impl bun_jsc::Taskable for YourType { const TAG; unsafe fn release_unrun(..) }`; +//! 3. a `run_task` arm and a `release_task_unrun` arm here; +//! 4. bump the `task_tag::COUNT` assertion below. // Flat re-export landing pad for `generated_js2native.rs` thunks. Kept in a // sibling file so this hot-path module stays focused on the task/timer/poll @@ -42,32 +43,21 @@ use bun_jsc::event_loop::{EventLoop, JsTerminated}; use bun_jsc::task::report_error_or_terminate; use bun_jsc::virtual_machine::VirtualMachine; -/// X-macro: the 42 `node:fs` async ops dispatched via `run_from_js_thread`. -/// -/// Row shape: `$tag $ty;` — `$tag` is the `bun_event_loop::task_tag::*` const, -/// `$ty` is the `fs_async::*` alias. They differ in exactly three rows -/// (`FTruncate`/`Ftruncate`, `FChown`/`Fchown`, `StatFS`/`Statfs`), so the -/// macro carries both idents. `ReaddirRecursive` is the bespoke -/// `AsyncReaddirRecursiveTask` (not an `AsyncFSTask<_,_,F>`); `Cp` and -/// `AsyncMkdirp` are intentionally absent — they have bespoke dispatch paths. -macro_rules! for_each_fs_async_op { +/// X-macro: the `node:fs` ops that are libuv requests on Windows +/// (`UVFSRequest`); they complete on the JS thread and re-enter through the +/// task queue under a per-op tag. Every other async fs op is a `bun_jsc::Job`. +/// Row shape: `$tag $ty;` (`task_tag::*` const, `fs_async::*` alias). +#[cfg(windows)] +macro_rules! for_each_fs_uv_op { ($m:ident) => { $m! { - Stat Stat; Lstat Lstat; Fstat Fstat; Open Open; ReadFile ReadFile; - WriteFile WriteFile; CopyFile CopyFile; Read Read; Write Write; - Truncate Truncate; Writev Writev; Readv Readv; Rename Rename; - FTruncate Ftruncate; Readdir Readdir; ReaddirRecursive ReaddirRecursive; - Close Close; Rm Rm; Rmdir Rmdir; Chown Chown; FChown Fchown; - Utimes Utimes; Lutimes Lutimes; Chmod Chmod; Fchmod Fchmod; Link Link; - Symlink Symlink; Readlink Readlink; Realpath Realpath; - RealpathNonNative RealpathNonNative; Mkdir Mkdir; Fsync Fsync; - Fdatasync Fdatasync; Access Access; AppendFile AppendFile; - Mkdtemp Mkdtemp; Exists Exists; Futimes Futimes; Lchmod Lchmod; - Lchown Lchown; Unlink Unlink; StatFS Statfs; + Open Open; Close Close; Read Read; Write Write; Readv Readv; + Writev Writev; StatFS Statfs; } }; } /// Expand the fs-op table to an or-pattern over `task_tag::*` (pattern position). +#[cfg(windows)] macro_rules! __fs_pat { ($($tag:ident $ty:ident;)*) => { $(task_tag::$tag)|* }; } @@ -75,11 +65,6 @@ macro_rules! __fs_pat { // ── per-variant payload types ──────────────────────────────────────────────── // (high-tier owns them all; grouped by source module) -use crate::api::archive::{ - AsyncTask as ArchiveAsyncTask, BlobTask as ArchiveBlobTask, ExtractTask as ArchiveExtractTask, - FilesTask as ArchiveFilesTask, WriteTask as ArchiveWriteTask, -}; - use crate::shell::builtins::{ cp::ShellCpTask, ls::ShellLsTask, @@ -89,18 +74,12 @@ use crate::shell::builtins::{ touch::ShellTouchTask, yes::YesTask as ShellYesTask, }; -use crate::shell::dispatch_tasks::{ - AsyncDeinitReader as ShellIOReaderAsyncDeinit, AsyncDeinitWriter as ShellIOWriterAsyncDeinit, - ShellAsyncSubprocessDone, ShellCondExprStatTask, ShellGlobTask, ShellRmDirTask, -}; +use crate::shell::dispatch_tasks::{ShellCondExprStatTask, ShellGlobTask, ShellRmDirTask}; use crate::shell::interpreter::ShellTask; #[cfg(not(windows))] use crate::shell::io_writer::Poll as ShellBufferedWriterPoll; use crate::shell::states::r#async::Async as ShellAsync; -use crate::webcore::blob::copy_file::CopyFilePromiseTask; -use crate::webcore::blob::read_file::ReadFileTask; -use crate::webcore::blob::write_file::WriteFileTask; use crate::webcore::fetch::fetch_tasklet::FetchTasklet; use crate::webcore::file_sink::FlushPendingTask as FlushPendingFileSinkTask; #[cfg(not(windows))] @@ -109,14 +88,11 @@ use crate::webcore::s3::download_stream::S3HttpDownloadStreamingTask; use crate::webcore::s3::simple_request::S3HttpSimpleTask; use crate::webcore::streams::Pending as StreamPending; -use crate::api::JSTranspiler::AsyncTransformTask; use crate::api::bun_subprocess::Subprocess; #[cfg(not(windows))] use crate::api::bun_terminal_body::Poll as TerminalPoll; use crate::api::cron::CronJob; -use crate::api::glob::AsyncGlobWalkTask; use crate::api::native_promise_context::DeferredDerefTask as NativePromiseContextDeferredDerefTask; -use crate::image::AsyncImageTask; #[cfg(not(windows))] use bun_spawn::static_pipe_writer::Poll as StaticPipeWriterPoll; @@ -132,6 +108,7 @@ use crate::bake::dev_server::DevServer; use crate::bake::dev_server::HotReloadEvent as BakeHotReloadEvent; use crate::bake::dev_server::source_map_store::SourceMapStore; +#[cfg(windows)] use crate::node::fs::async_ as fs_async; use crate::node::node_fs_stat_watcher::StatWatcherScheduler; use crate::node::node_fs_watcher::FSWatchTask; @@ -141,8 +118,6 @@ use crate::node::zlib::{ }; use crate::dns_jsc::Resolver as DNSResolver; -#[cfg(not(windows))] -use crate::dns_jsc::get_addr_info_request; use crate::server::ServerAllConnectionsClosedTask; #[cfg(not(windows))] @@ -223,36 +198,15 @@ pub(crate) fn run_task( }; }}; } - /// Run the task, destroy it unconditionally (whether or not it errored), - /// then propagate. `JsTerminated` tears down the VM, so destroying before - /// propagating is safe. - macro_rules! run_then_destroy { - ($ty:ty) => {{ - let t = cast_ptr!($ty); - // SAFETY: tag identifies pointee; heap-allocated at schedule time. - let r = unsafe { (*t).run_from_js() }; - // SAFETY: paired with `create_on_js_thread` heap::alloc. - unsafe { <$ty>::destroy(t) }; - r?; - }}; - (work $ty:ty) => {{ - let t = cast_ptr!($ty); - // SAFETY: tag identifies pointee; heap-allocated at schedule time. - let r = bun_jsc::work_task::WorkTask::run_from_js(unsafe { &mut *t }); - // SAFETY: paired with `create_on_js_thread` heap::alloc. - unsafe { bun_jsc::work_task::WorkTask::destroy(t) }; - r?; - }}; - } - // NB: `TaskTag` is `#[derive(PartialEq, Eq)]` over `u8` → structural-match // eligible, so const patterns work directly. match task.tag { // ── erased-callback tasks (low-tier types — real) ──────────────── task_tag::AnyTaskJob => { - // SAFETY: §Dispatch — `task.ptr` is a live heap `AnyTaskJob` - // enqueued by `AnyTaskJob::run_task`; the erased entry frees it. - if let Err(err) = unsafe { bun_jsc::any_task_job::dispatch_erased(task.ptr) } { + // SAFETY: §Dispatch — `task.ptr` is a live heap `Job` posted by + // its `Completion`; the erased entry runs `then` and frees it. + let completed = unsafe { bun_jsc::job::complete_erased(task.ptr, &global.js_thread()) }; + if let Err(err) = completed { report_error_or_terminate(global, err)?; } } @@ -346,19 +300,25 @@ pub(crate) fn run_task( } .run(); } - task_tag::PasswordHashResult => { - crate::crypto::password_object::PasswordResult::::run_from_js( - cast_ptr!(crate::crypto::password_object::PasswordResult), - )?; - } - task_tag::PasswordVerifyResult => { - crate::crypto::password_object::PasswordResult::< - crate::crypto::password_object::VerifyOp, - >::run_from_js(cast_ptr!( - crate::crypto::password_object::PasswordResult< - crate::crypto::password_object::VerifyOp, - > - ))?; + task_tag::AsyncCpTask => { + // SAFETY: posted by `on_subtask_done` with the count at zero (exclusive). + unsafe { (*task.ptr.cast::()).run_from_js_thread()? }; + } + task_tag::ShellAsyncCpTask => { + // SAFETY: as above. + unsafe { + (*task.ptr.cast::()).run_from_js_thread()? + }; + } + task_tag::StatWatcherHop => { + // SAFETY: posted by `StatWatcher::post_to_js_thread` with a ref held. + if let Err(err) = unsafe { + crate::node::node_fs_stat_watcher::StatWatcher::run_hop(cast_ptr!( + crate::node::node_fs_stat_watcher::StatWatcher + )) + } { + report_error_or_terminate(global, bun_jsc::JsError::from(err))?; + } } task_tag::ManagedTask => { // SAFETY: `task.ptr` was produced by `heap::alloc` in `ManagedTask::new` @@ -373,27 +333,8 @@ pub(crate) fn run_task( } } - // ── archive ────────────────────────────────────────────────────── - // `cast_ptr!` yields the heap-allocated task registered with this - // tag; the JS-thread dispatch is the sole owner at this point. - task_tag::ArchiveExtractTask => { - ArchiveAsyncTask::run_from_js(cast_ptr!(ArchiveExtractTask))?; - } - task_tag::ArchiveBlobTask => { - ArchiveAsyncTask::run_from_js(cast_ptr!(ArchiveBlobTask))?; - } - task_tag::ArchiveWriteTask => { - ArchiveAsyncTask::run_from_js(cast_ptr!(ArchiveWriteTask))?; - } - task_tag::ArchiveFilesTask => { - ArchiveAsyncTask::run_from_js(cast_ptr!(ArchiveFilesTask))?; - } - // ── shell interpreter (cold — hoisted to `run_task_cold`) ──────── task_tag::ShellAsync - | task_tag::ShellAsyncSubprocessDone - | task_tag::ShellIOWriterAsyncDeinit - | task_tag::ShellIOReaderAsyncDeinit | task_tag::ShellCondExprStatTask | task_tag::ShellCpTask | task_tag::ShellTouchTask @@ -410,6 +351,14 @@ pub(crate) fn run_task( task_tag::FetchTasklet => { cast!(FetchTasklet).on_progress_update()?; } + task_tag::FetchTaskletDeinit => { + // SAFETY: posted by `deref_from_thread` with the last ref. + unsafe { + crate::webcore::fetch::FetchTaskletDeinitHop::run(cast_ptr!( + crate::webcore::fetch::FetchTaskletDeinitHop + )) + }; + } // `cast_ptr!` yields the heap-allocated S3 task; JS-thread dispatch // is the sole owner here. task_tag::S3HttpSimpleTask => { @@ -419,16 +368,6 @@ pub(crate) fn run_task( S3HttpDownloadStreamingTask::on_response(cast_ptr!(S3HttpDownloadStreamingTask)); } - // ── glob / image / transpiler ──────────────────────────────────── - task_tag::AsyncGlobWalkTask => run_then_destroy!(AsyncGlobWalkTask<'_>), - task_tag::AsyncImageTask => run_then_destroy!(AsyncImageTask<'_>), - task_tag::AsyncTransformTask => run_then_destroy!(AsyncTransformTask<'_>), - - // ── blob copy/read/write promise tasks ─────────────────────────── - task_tag::CopyFilePromiseTask => run_then_destroy!(CopyFilePromiseTask<'_>), - task_tag::ReadFileTask => run_then_destroy!(work ReadFileTask), - task_tag::WriteFileTask => run_then_destroy!(work WriteFileTask), - // ── napi ───────────────────────────────────────────────────────── task_tag::NapiAsyncWork => { cast!(napi_async_work).run_from_js(vm, global); @@ -485,36 +424,23 @@ pub(crate) fn run_task( unsafe { FSWatchTask::deinit(t) }; } - // ── DNS ────────────────────────────────────────────────────────── - task_tag::GetAddrInfoRequestTask => { - #[cfg(windows)] - panic!("This should not be reachable on Windows"); - #[cfg(not(windows))] - run_then_destroy!(work get_addr_info_request::Task); - } - - // ── node:fs async ops (`runFromJSThread`) ──────────────────────── - // 42 arms stamped from `for_each_fs_async_op!` (module scope). The - // outer or-pattern proves the inner re-match is exhaustive over the - // table, so the trailing wildcard is genuinely unreachable. - for_each_fs_async_op!(__fs_pat) => { + // ── node:fs libuv-request ops (Windows) ────────────────────────── + #[cfg(windows)] + for_each_fs_uv_op!(__fs_pat) => { macro_rules! __fs_run { ($($tag:ident $ty:ident;)*) => { match task.tag { $(task_tag::$tag => cast!(fs_async::$ty).run_from_js_thread()?,)* - // SAFETY: outer arm guard proves one of the 42 tags matched. + // SAFETY: outer arm guard proves one of the table tags matched. _ => unsafe { core::hint::unreachable_unchecked() }, }}; } - for_each_fs_async_op!(__fs_run); + for_each_fs_uv_op!(__fs_run); } // ── compression streams ────────────────────────────────────────── task_tag::NativeZlib => compression_arm!(NativeZlib), task_tag::NativeBrotli => compression_arm!(NativeBrotli), task_tag::NativeZstd => compression_arm!(NativeZstd), - task_tag::CompressionStreamCoderTask => { - run_then_destroy!(work crate::webcore::compression_stream_coder::CompressionStreamCoderTask) - } // ── process / signals ──────────────────────────────────────────── task_tag::ProcessWaiterThreadTask => { @@ -547,10 +473,9 @@ pub(crate) fn run_task( // ── server / bundler / streams ─────────────────────────────────── task_tag::ServerAllConnectionsClosedTask => { - ServerAllConnectionsClosedTask::run_from_js_thread( - cast_ptr!(ServerAllConnectionsClosedTask), - vm, - )?; + ServerAllConnectionsClosedTask::run_from_js_thread(cast_ptr!( + ServerAllConnectionsClosedTask + ))?; } task_tag::BundleV2DeferredBatchTask => { // `bun_bundler` is JSC-free so the exception-scope check is hoisted @@ -570,15 +495,6 @@ pub(crate) fn run_task( StreamPending::run_from_js_thread(cast_ptr!(StreamPending)); } - // ── timer wrappers (declared in the union but never dispatched) ── - task_tag::ImmediateObject | task_tag::TimeoutObject => { - // This is a *reachable* producer bug (timer object enqueued as Task), - // not provable-unreachable — `unreachable_unchecked()` here would be - // release-build UB. PORTING.md §Dispatch only sanctions UB for the - // truly-unreachable wildcard. - panic!("Unexpected Task tag: {}", task.tag.0); - } - _ => { // A value outside `task_tag::COUNT` is a producer bug, but it's // treated as a recoverable crash, not UB. @@ -645,18 +561,6 @@ fn run_task_cold(task: Task) { let interp = unsafe { &*t.interp }; ShellAsync::run_from_main_thread(interp, t.node); } - task_tag::ShellAsyncSubprocessDone => { - let t = cast_ptr!(ShellAsyncSubprocessDone); - ShellAsyncSubprocessDone::run_from_main_thread(t); - } - task_tag::ShellIOWriterAsyncDeinit => { - let t = cast_ptr!(ShellIOWriterAsyncDeinit); - ShellIOWriterAsyncDeinit::run_from_main_thread(t); - } - task_tag::ShellIOReaderAsyncDeinit => { - let t = cast_ptr!(ShellIOReaderAsyncDeinit); - ShellIOReaderAsyncDeinit::run_from_main_thread(t); - } task_tag::ShellCondExprStatTask => { shell_dispatch!(nested ShellCondExprStatTask); } @@ -695,11 +599,12 @@ fn run_task_cold(task: Task) { } } -/// Compile-time guard that the arm count above tracks -/// `bun_event_loop::task_tag::COUNT`. Bump when adding a variant. +/// Compile-time guard that the arm counts in `run_task` and +/// `release_task_unrun` track `bun_event_loop::task_tag::COUNT`. Bump when +/// adding a variant — and give it an arm in both. const _: () = assert!( - task_tag::COUNT == 112, - "dispatch::run_task arm count out of sync with bun_event_loop::task_tag", + task_tag::COUNT == 62, + "dispatch::run_task / release_task_unrun arm count out of sync with bun_event_loop::task_tag", ); // ──────────────────────────────────────────────────────────────────────────── @@ -1258,116 +1163,136 @@ unsafe fn __bun_tick_queue_with_count( // (former duplicate `__bun_run_tasks` removed r6 — `bun_jsc::task::run_tasks` // had no callers; `__bun_tick_queue_with_count` above is the sole entry point.) -/// `__bun_release_task_at_shutdown` body — declared `extern "Rust"` in -/// `bun_jsc::event_loop`. Called from `release_queued_tasks_for_shutdown` on -/// the JS thread for every queued task that will never be dispatched (the JS -/// thread is past `global_exit`'s `is_shutting_down` flip and the loop will -/// not tick again), after the HTTP daemon has parked and before -/// `destructOnExit`. Releases the boxes and JSC handles the dispatch path -/// would have dropped. Tags not yet listed leak their box at exit; add them -/// as LSan surfaces them. +/// `__bun_release_task_unrun` — declared `extern "Rust"` in +/// `bun_jsc::event_loop`. A queued task that will never be dispatched (its VM +/// is tearing down: script is forbidden and the loop no longer ticks) is freed +/// through its type's [`Taskable::release_unrun`](bun_event_loop::Taskable). +/// One arm per tag, no fallthrough: a tag cannot exist without its type +/// having decided how it is released. JS thread, JSC heap alive. #[unsafe(no_mangle)] -fn __bun_release_task_at_shutdown(task: bun_event_loop::Task) -> bool { - use bun_event_loop::task_tag; +fn __bun_release_task_unrun(task: bun_event_loop::Task) { + use bun_event_loop::{Taskable, task_tag}; + /// `::release_unrun(task.ptr as *mut T)`, SAFETY spelled once. + macro_rules! release { + ($ty:ty) => {{ + // SAFETY: §Dispatch — `task.tag` was set together with `task.ptr` + // through `Taskable`; the tag identifies the pointee type, and the + // task just came off the queue and is not used afterwards. + unsafe { <$ty as Taskable>::release_unrun(task.ptr.cast::<$ty>()) } + }}; + } match task.tag { - // `callback` (HTTP thread) won the `has_schedule_callback` CAS and - // posted this entry, then deref'd its own +1 if final; the JS-side - // +1 it expected `on_progress_update` to drop is the one we release - // here. Runs on the JS thread, so the plain `deref` (→ `deinit` on - // 1→0) is the right teardown path; the HTTP daemon is already - // parked (`shutdown_for_exit` precedes `destroy`), so the - // `Box` and any `metadata` it owns are exclusively ours. - 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::()); - true + task_tag::AnyTaskJob => { + // The one erased tag: every payload is a `Job` reached through its header. + let js = VirtualMachine::get().global().js_thread(); + // SAFETY: as `release!`. + unsafe { bun_jsc::job::release_unrun_erased(task.ptr, &js) } + } + task_tag::AsyncModule => release!(bun_jsc::async_module::AsyncModule), + task_tag::BakeHotReloadEvent => release!(BakeHotReloadEvent), + task_tag::BundleV2DeferredBatchTask => release!(BundleV2DeferredBatchTask), + task_tag::BundleV2PluginResolve => { + release!(bun_bundler::bundle_v2::api::JSBundler::Resolve) + } + task_tag::BundleV2PluginLoad => release!(bun_bundler::bundle_v2::api::JSBundler::Load), + task_tag::ShellYesTask => release!(ShellYesTask), + task_tag::CppTask => release!(CppTask), + task_tag::DuplexUpgradeContext => release!(crate::socket::DuplexUpgradeContext), + task_tag::FetchTasklet => release!(FetchTasklet), + task_tag::FetchTaskletDeinit => release!(crate::webcore::fetch::FetchTaskletDeinitHop), + task_tag::FetchTaskletPromiseSettle => { + release!(crate::webcore::fetch::fetch_tasklet::FetchTaskletPromiseSettle) } - task_tag::SendQueueDeferred => { - // SAFETY: `task.ptr` is the SendQueue root queued with a held ref. - unsafe { - crate::ipc::SendQueue::release_deferred_unrun( - task.ptr.cast::(), - ) - }; - true + task_tag::FileResponseStreamEof => release!(crate::server::FileResponseStream), + task_tag::FSWatchTask => release!(FSWatchTask), + task_tag::HotReloadTask => release!(hot_reloader::HotReloadTask), + task_tag::WatchReloadTask => release!(hot_reloader::WatchReloadTask), + task_tag::JSBundleCompletionTask => { + release!(crate::api::js_bundle_completion_task::JSBundleCompletionTask) } - task_tag::FileResponseStreamEof => { - // SAFETY: `on_read_chunk` took a ref for the queued task; adopt it. - drop(unsafe { - bun_ptr::ScopedRef::::adopt( - task.ptr.cast::(), - ) - }); - true - } - // `AsyncFSTask`s are `Box::leak`'d in `create()` and freed by - // `destroy()` (called from `run_from_js_thread`'s scopeguard). - // `destroy()` resets `JSPromiseStrong` (touches the StrongRootBlock list) - // and unrefs the loop `KeepAlive`, both of which are still valid - // here — we're before `destructOnExit`. Before - // `release_queued_tasks_for_shutdown` existed these boxes stayed - // reachable via `concurrent_tasks` (rooted by the static `VMHolder`), - // so LSan didn't flag them; the drain unhooks that root and surfaces - // the real leak. - for_each_fs_async_op!(__fs_pat) => { - macro_rules! __fs_destroy { - ($($tag:ident $ty:ident;)*) => { match task.tag { - $(task_tag::$tag => { - // SAFETY: tag identifies pointee; `Box::leak`'d in - // `AsyncFSTask::create`. The work-pool callback ran - // (it posted this entry) so the threadpool no longer - // holds the embedded `task` field. - unsafe { fs_async::$ty::destroy(task.ptr.cast::()) }; - })* - // SAFETY: outer arm guard proves one of the table tags matched. - _ => unsafe { core::hint::unreachable_unchecked() }, - }}; - } - for_each_fs_async_op!(__fs_destroy); - true - } - // A cross-thread Atomics.notify (or Wasm/FinalizationRegistry - // completion) enqueued this after the event loop's last tick. The - // dispatch arm above would have `delete`d it; mirror that here so the - // re-queue path doesn't keep it alive past worker VM dealloc. Runs - // before JSC teardown, so ~Ref is safe. - task_tag::JSCDeferredWorkTask => { - unsafe extern "C" { - fn Bun__deleteDeferredWorkTask(task: *mut JSCDeferredWorkTask); - } - // SAFETY: every JSCDeferredWorkTask payload is heap-allocated by - // `new JSCDeferredWorkTask` in JSCTaskScheduler::onScheduleWorkSoon; - // we own it once popped. - unsafe { Bun__deleteDeferredWorkTask(task.ptr.cast::()) }; - true - } - // Same reclaim `drop_concurrent_cpp_tasks` performs, but for tasks - // that were already batch-moved into `self.tasks`. Must run before - // JSC teardown: a Worker `dispatchExit` lambda's `~Ref` walks - // `~JSEventListener` Weak<> handles. Worker `shutdown()` calls - // `release_queued_tasks_for_shutdown` for the same reason. - task_tag::CppTask => { - unsafe extern "C" { - fn Bun__deleteEventLoopTask(task: *mut CppTask); + task_tag::JSCDeferredWorkTask => release!(JSCDeferredWorkTask), + task_tag::ManagedTask => release!(ManagedTask), + task_tag::NapiAsyncWork => release!(napi_async_work), + task_tag::NapiFinalizerTask => release!(NapiFinalizerTask), + task_tag::NativePromiseContextDeferredDerefTask => { + release!(NativePromiseContextDeferredDerefTask) + } + task_tag::NativeBrotli => release!(NativeBrotli), + task_tag::NativeZlib => release!(NativeZlib), + task_tag::NativeZstd => release!(NativeZstd), + task_tag::PollPendingModulesTask => release!(bun_jsc::async_module::Queue), + task_tag::PosixSignalTask => release!(PosixSignalTask), + task_tag::MemoryPressureTask => release!(crate::node::memory_pressure::MemoryPressureTask), + task_tag::ProcessWaiterThreadTask => { + #[cfg(not(windows))] + release!(ProcessWaiterThreadTask); + #[cfg(windows)] + unreachable!("posix-only tag"); + } + task_tag::FlushPendingFileSinkTask => release!(FlushPendingFileSinkTask), + task_tag::RuntimeTranspilerStore => release!(RuntimeTranspilerStore), + task_tag::S3HttpDownloadStreamingTask => release!(S3HttpDownloadStreamingTask), + task_tag::S3HttpSimpleTask => release!(S3HttpSimpleTask), + task_tag::SendQueueDeferred => release!(crate::ipc::SendQueue), + task_tag::ServerAllConnectionsClosedTask => release!(ServerAllConnectionsClosedTask), + task_tag::ShellAsync => release!(crate::shell::dispatch_tasks::ShellAsyncTask), + task_tag::ShellCondExprStatTask => release!(ShellCondExprStatTask), + task_tag::ShellCpTask => release!(ShellCpTask), + task_tag::ShellGlobTask => release!(ShellGlobTask), + task_tag::ShellLsTask => release!(ShellLsTask), + task_tag::ShellMkdirTask => release!(ShellMkdirTask), + task_tag::ShellMvBatchedTask => release!(ShellMvBatchedTask), + task_tag::ShellMvCheckTargetTask => release!(ShellMvCheckTargetTask), + task_tag::ShellRmDirTask => release!(ShellRmDirTask), + task_tag::ShellRmTask => release!(ShellRmTask), + task_tag::ShellTouchTask => release!(ShellTouchTask), + task_tag::StatWatcherTimerUpdate => { + release!(crate::node::node_fs_stat_watcher::StatWatcherTimerUpdate) + } + task_tag::StatWatcherHop => release!(crate::node::node_fs_stat_watcher::StatWatcher), + task_tag::AsyncCpTask => release!(crate::node::fs::AsyncCpTask), + task_tag::ShellAsyncCpTask => release!(crate::node::fs::ShellAsyncCpTask), + task_tag::StreamPending => release!(StreamPending), + task_tag::ThreadSafeFunction => release!(ThreadSafeFunction), + task_tag::ValkeyDeferredClose => { + release!(crate::valkey_jsc::js_valkey::ValkeyDeferredClose) + } + // ── Windows-only producers ─────────────────────────────────────── + task_tag::GetAddrInfoLibuvComplete => { + #[cfg(windows)] + release!(crate::dns_jsc::LibuvCompleteHolder); + #[cfg(not(windows))] + unreachable!("windows-only tag"); + } + task_tag::WindowsNamedPipeContext => { + #[cfg(windows)] + release!(crate::socket::WindowsNamedPipeContext); + #[cfg(not(windows))] + unreachable!("windows-only tag"); + } + task_tag::Open + | task_tag::Close + | task_tag::Read + | task_tag::Readv + | task_tag::Write + | task_tag::Writev + | task_tag::StatFS => { + #[cfg(windows)] + { + macro_rules! __fs_release { + ($($tag:ident $ty:ident;)*) => { match task.tag { + $(task_tag::$tag => release!(fs_async::$ty),)* + // SAFETY: the outer arm proves one of the table tags matched. + _ => unsafe { core::hint::unreachable_unchecked() }, + }}; + } + for_each_fs_uv_op!(__fs_release); } - // SAFETY: every CppTask payload is a heap `WebCore::EventLoopTask*`; - // we own it once popped. - unsafe { Bun__deleteEventLoopTask(task.ptr.cast::()) }; - true + #[cfg(not(windows))] + unreachable!("windows-only tag (libuv fs request)"); } - // Queue presence means the work-pool phase finished and the queue - // owned the job; parking it would strand the ctx's native resources. - task_tag::AnyTaskJob => { - // SAFETY: every queued AnyTaskJob payload is the live heap job - // created by `AnyTaskJob::create`; we own it once popped. - unsafe { bun_jsc::any_task_job::release_erased(task.ptr) }; - true - } - // Re-queued by the caller; the box stays reachable from the - // static-rooted VM queue, because running these callbacks - // is not generally safe at shutdown (e.g. `AsyncModule::on_done`, - // `dns::Holder::run` call straight into JS). - _ => false, + // Every tag has an arm above (`task_tag::COUNT` is asserted); a value + // outside the range is a producer bug. + _ => unreachable!("task tag out of range: {}", task.tag.0), } } diff --git a/src/runtime/dns_jsc/cares_jsc.rs b/src/runtime/dns_jsc/cares_jsc.rs index 0cdceff6d73e..95314b7a9f7d 100644 --- a/src/runtime/dns_jsc/cares_jsc.rs +++ b/src/runtime/dns_jsc/cares_jsc.rs @@ -720,7 +720,7 @@ impl ErrorDeferred { } let vm = global_this.bun_vm(); - // Worker terminate's `close_dns_for_terminate` fires EDESTRUCTION with + // Worker terminate's `stop_dns_for_vm_teardown` fires EDESTRUCTION with // `is_shutting_down` already set; the task queue is about to be // drained-without-run and ManagedTask has no cleanup here, so enqueuing // would leak the `Context` and its `JSPromiseStrong` box. Drop now while diff --git a/src/runtime/dns_jsc/dns.rs b/src/runtime/dns_jsc/dns.rs index 2f5dd6fe635c..950620131d25 100644 --- a/src/runtime/dns_jsc/dns.rs +++ b/src/runtime/dns_jsc/dns.rs @@ -30,7 +30,6 @@ use bun_paths::{MAX_PATH_BYTES, PathBuffer}; use bun_sys::windows::libuv; #[cfg(not(windows))] use bun_sys::{self as sys}; -use bun_threading::thread_pool; use bun_uws::{ConnectingSocket, Loop}; use bun_wyhash::hash as wyhash; @@ -149,11 +148,11 @@ mod lib_c { let query = query_init.clone(); + // The backend result is filled in by the job's completion; until then + // the request (which the pending cache points at) holds a placeholder. let request = GetAddrInfoRequest::init( cache, - get_addr_info_request::Backend::Libc(get_addr_info_request::LibcBackend::Query( - query.clone(), - )), + get_addr_info_request::Backend::CAres, Some(this.as_ctx_ptr()), &query, global_this, @@ -162,9 +161,13 @@ mod lib_c { // SAFETY: request was just heap-allocated in init() and is exclusively owned here. let promise_value = unsafe { (*request).head.promise.value() }; - let io = get_addr_info_request::Task::create_on_js_thread(global_this, request); - // SAFETY: `io` was just heap-allocated by `create_on_js_thread`. - get_addr_info_request::Task::schedule(unsafe { &mut *io }); + bun_jsc::Job::::schedule( + &global_this.js_thread(), + get_addr_info_request::LibcLookup { + backend: get_addr_info_request::LibcBackend::Query(query), + }, + get_addr_info_request::LibcRequest(NonNull::new(request).expect("request")), + ); this.request_sent(this.vm()); promise_value @@ -191,6 +194,13 @@ pub(crate) mod lib_uv_backend { } impl bun_event_loop::Taskable for LibuvCompleteHolder { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::GetAddrInfoLibuvComplete; + /// A uv_getaddrinfo the stop phase cancelled and drained into the queue: + /// its completion is what frees the request and its cache slot, and it + /// only settles promises (no callback runs; script is forbidden), so run it. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — the box `on_raw_libuv_complete` queued. + unsafe { bun_core::heap::take(this) }.run(); + } } extern "C" fn on_raw_libuv_complete( @@ -725,19 +735,21 @@ impl CAresNameInfo { } return; }; - let array = super::cares_jsc::nameinfo_to_js_response(&mut name_info, global_this) - .unwrap_or(JSValue::ZERO); // TODO: properly propagate exception upwards + let array = Outcome::of( + global_this, + super::cares_jsc::nameinfo_to_js_response(&mut name_info, global_this), + ); // SAFETY: see fn contract. unsafe { Self::on_complete(this, array) }; } /// SAFETY: see `process_resolve`. - unsafe fn on_complete(this: *mut Self, result: JSValue) { + unsafe fn on_complete(this: *mut Self, result: Outcome) { // SAFETY: see fn contract — `this` is a live node. let mut promise = unsafe { core::mem::take(&mut (*this).promise) }; // SAFETY: see fn contract — `this` is a live node. let global_this = unsafe { (*this).global_this() }; - let _ = promise.resolve_task(global_this, result); // TODO: properly propagate exception upwards + result.settle(&mut promise, global_this); // SAFETY: see fn contract. unsafe { Self::destroy(this) }; } @@ -914,15 +926,78 @@ pub struct GetAddrInfoRequest { pub(crate) cache: CacheConfig, pub(crate) head: DNSLookup, pub(crate) tail: *mut DNSLookup, // INTRUSIVE - pub task: thread_pool::Task, } pub mod get_addr_info_request { use super::*; - /// `bun.jsc.WorkTask(GetAddrInfoRequest)` — runs blocking `getaddrinfo` - /// on the work pool, then re-enters the JS thread via `then`. - pub type Task = jsc::work_task::WorkTask; + /// The blocking `getaddrinfo` of one libc-backend lookup, run on the pool. + #[cfg(not(windows))] + pub struct LibcLookup { + pub(crate) backend: LibcBackend, + } + + /// The request a [`LibcLookup`] completes: JS-thread state (promises, + /// keep-alive, resolver ref) at a stable address the resolver's pending + /// cache points at. Consumed by the completion; dropped unconsumed only + /// when the VM tears down first, in which case everything is freed and + /// nothing is settled. + #[cfg(not(windows))] + pub struct LibcRequest(pub(crate) NonNull); + // SAFETY: only the JS thread touches the request (see type doc). + #[cfg(not(windows))] + unsafe impl bun_jsc::job::JsAffine for LibcRequest {} + #[cfg(not(windows))] + impl Drop for LibcRequest { + fn drop(&mut self) { + let req = self.0.as_ptr(); + // SAFETY: JS thread; the live heap request and its coalesced + // waiters, none of which anything else will touch again. + unsafe { + if let Some(resolver) = (*req).resolver_for_caching { + if (*req).cache.pending_cache() { + drop((*resolver).get_key_host( + (*req).cache.pos_in_pending(), + PendingCacheField::PendingHostCacheNative, + )); + } + } + let mut pending = (*req).head.next; + drop(*bun_core::heap::take(req)); + while let Some(waiter) = pending { + pending = (*waiter.as_ptr()).next; + drop(bun_core::heap::take(waiter.as_ptr())); + } + } + } + } + + #[cfg(not(windows))] + impl bun_jsc::JobContext for LibcLookup { + type OffThread = Self; + type Js = LibcRequest; + fn run( + this: &mut Self, + _vm: &bun_jsc::vm_handle::Borrow, + done: bun_jsc::Completion, + ) -> Option> { + this.backend.run(); + Some(done) + } + fn then( + this: Self, + request: LibcRequest, + cx: &bun_jsc::JsThread<'_>, + ) -> bun_jsc::JsResult<()> { + // Consumed here: `then` takes over the request on every path, so + // the release-on-drop must not run. + let req = core::mem::ManuallyDrop::new(request).0.as_ptr(); + // SAFETY: the live heap request; `then` consumes it on every path. + unsafe { (*req).backend = Backend::Libc(this.backend) }; + super::GetAddrInfoRequest::then(req, cx.global()); + Ok(()) + } + } pub struct PendingCacheKey { pub(crate) hash: u64, @@ -1050,9 +1125,6 @@ pub mod get_addr_info_request { uv: bun_core::ffi::zeroed(), } } - pub(crate) fn run(&mut self) { - unreachable!("This path should never be reached on Windows"); - } } pub enum Backend { CAres, @@ -1079,30 +1151,6 @@ pub mod get_addr_info_request { } } -// `WorkTaskContext` fixes `run`/`then` to take `*mut Self`; the trait method -// cannot be marked `unsafe fn` and the parameter type cannot change, so the -// lint is unsatisfiable here. The pointers come from the work-pool hand-off -// and are guaranteed live (see SAFETY notes below). -#[allow(clippy::not_unsafe_ptr_arg_deref)] -impl jsc::work_task::WorkTaskContext for GetAddrInfoRequest { - const TASK_TAG: bun_event_loop::ConcurrentTask::TaskTag = - bun_event_loop::ConcurrentTask::task_tag::GetAddrInfoRequestTask; - - #[inline] - fn run(this: *mut Self, task: *mut get_addr_info_request::Task) { - // SAFETY: `WorkTask` invokes `run` on the threadpool with the live heap - // `GetAddrInfoRequest` it was created from and its owning `WorkTask`. - GetAddrInfoRequest::run(unsafe { &mut *this }, unsafe { &mut *task }); - } - #[inline] - fn then(this: *mut Self, global_this: &JSGlobalObject) -> Result<(), jsc::JsTerminated> { - // SAFETY: `WorkTask` invokes `then` on the JS thread with the same live - // heap request that `run` was given. - GetAddrInfoRequest::then(this, global_this); - Ok(()) - } -} - impl GetAddrInfoRequest { pub(crate) fn init( cache: CacheHit, @@ -1129,18 +1177,6 @@ impl GetAddrInfoRequest { next: None, }, tail: ptr::null_mut(), - // The callback is - // overwritten before scheduling; use a trapping stub so the - // non-null fn-pointer invariant holds without `mem::zeroed()` UB. - task: thread_pool::Task { - node: Default::default(), - callback: { - unsafe fn unset(_: *mut thread_pool::Task) { - unreachable!("GetAddrInfoRequest.task scheduled without callback"); - } - unset - }, - }, })); // SAFETY: request just allocated; head is an inline field. unsafe { (*request).tail = &raw mut (*request).head }; @@ -1235,16 +1271,7 @@ impl GetAddrInfoRequest { } } - /// `this` must be the live heap `GetAddrInfoRequest` owned by `task`, and - /// `task` the live `WorkTask` passed in by `run_from_thread_pool`. - pub(crate) fn run(this: &mut Self, task: &mut get_addr_info_request::Task) { - match &mut this.backend { - get_addr_info_request::Backend::Libc(l) => l.run(), - _ => unreachable!(), - } - get_addr_info_request::Task::on_finish(task); - } - + #[cfg(not(windows))] /// # Safety /// `this` must be the live heap `GetAddrInfoRequest` whose `run` already /// completed; consumed (freed) on every path. @@ -1254,9 +1281,8 @@ impl GetAddrInfoRequest { #[allow(clippy::not_unsafe_ptr_arg_deref)] pub(crate) fn then(this: *mut Self, _global: &JSGlobalObject) { bun_output::scoped_log!(GetAddrInfoRequest, "then"); - #[cfg(not(windows))] - // SAFETY: WorkTask invokes `then` on the JS thread with the heap request it - // was created from; `resolver_for_caching` (if set) is the live ctx ref. + // SAFETY: called on the JS thread with the heap request the lookup was + // created from; `resolver_for_caching` (if set) is the live ctx ref. unsafe { // Take the backend by value: `Success` holds a `Vec` // (not `Clone`) that we move into `GetAddrInfoResultAny::List`. The @@ -1309,11 +1335,6 @@ impl GetAddrInfoRequest { _ => unreachable!(), } } - #[cfg(windows)] - { - let _ = this; - unreachable!() - } } /// # Safety @@ -1504,19 +1525,21 @@ impl CAresReverse { return; }; // node is a valid c-ares hostent for the callback's duration - let array = super::cares_jsc::hostent_to_js_response(&mut *node, global_this, b"") - .unwrap_or(JSValue::ZERO); // TODO: properly propagate exception upwards + let array = Outcome::of( + global_this, + super::cares_jsc::hostent_to_js_response(&mut *node, global_this, b""), + ); Self::on_complete(this, array); } } /// SAFETY: see `process_resolve`. - unsafe fn on_complete(this: *mut Self, result: JSValue) { + unsafe fn on_complete(this: *mut Self, result: Outcome) { // SAFETY: caller contract — `this` is live; JSGlobalObject outlives the request. unsafe { let mut promise = core::mem::take(&mut (*this).promise); let global_this = (*this).global_this(); - let _ = promise.resolve_task(global_this, result); // TODO: properly propagate exception upwards + result.settle(&mut promise, global_this); if let Some(resolver) = (*this).resolver.as_ref() { // IntrusiveRc holds a live ref; request_completed mutates pending_requests counter only. (*resolver.as_ptr()).request_completed(); @@ -1653,20 +1676,21 @@ impl CAresLookup { }; // node is a valid c-ares reply for the callback's duration; freed by `_free` guard. - let array = (*node) - .to_js_response(global_this, T::TYPE_NAME) - .unwrap_or(JSValue::ZERO); // TODO: properly propagate exception upwards + let array = Outcome::of( + global_this, + (*node).to_js_response(global_this, T::TYPE_NAME), + ); Self::on_complete(this, array); } } /// SAFETY: see `process_resolve`. - unsafe fn on_complete(this: *mut Self, result: JSValue) { + unsafe fn on_complete(this: *mut Self, result: Outcome) { // SAFETY: caller contract — `this` is live; JSGlobalObject outlives the request. unsafe { let mut promise = core::mem::take(&mut (*this).promise); let global_this = (*this).global_this(); - let _ = promise.resolve_task(global_this, result); // TODO: properly propagate exception upwards + result.settle(&mut promise, global_this); if let Some(resolver) = (*this).resolver.as_ref() { // IntrusiveRc holds a live ref; request_completed mutates pending_requests counter only. (*resolver.as_ptr()).request_completed(); @@ -1753,11 +1777,11 @@ impl DNSLookup { bun_output::scoped_log!(DNSLookup, "onCompleteNative"); // SAFETY: caller contract — `this` is live; JSGlobalObject outlives the request. unsafe { - let array = super::options_jsc::result_any_to_js(result, (*this).global_this()) - .ok() - .flatten() - .unwrap_or(JSValue::ZERO); // TODO: properly propagate exception upwards - Self::on_complete_with_array(this, array); + let global = (*this).global_this(); + // A null addrinfo with no error is an empty answer. + let array = super::options_jsc::result_any_to_js(result, global) + .and_then(|a| a.map_or_else(|| JSValue::create_empty_array(global, 0), Ok)); + Self::on_complete_with_array(this, Outcome::of(global, array)); } } @@ -1826,21 +1850,20 @@ impl DNSLookup { // SAFETY: caller contract — `this` is live; result is a live c-ares AddrInfo // owned by the caller's scopeguard; JSGlobalObject outlives the request. unsafe { - let array = - super::cares_jsc::addr_info_to_js_array(&mut *result, (*this).global_this()) - .unwrap_or(JSValue::ZERO); // TODO: properly propagate exception upwards - Self::on_complete_with_array(this, array); + let global = (*this).global_this(); + let array = super::cares_jsc::addr_info_to_js_array(&mut *result, global); + Self::on_complete_with_array(this, Outcome::of(global, array)); } } /// SAFETY: see `on_complete_native`. - unsafe fn on_complete_with_array(this: *mut Self, result: JSValue) { + unsafe fn on_complete_with_array(this: *mut Self, result: Outcome) { bun_output::scoped_log!(DNSLookup, "onCompleteWithArray"); // SAFETY: caller contract — `this` is live; JSGlobalObject outlives the request. unsafe { let mut promise = core::mem::take(&mut (*this).promise); let global_this = (*this).global_this(); - let _ = promise.resolve_task(global_this, result); // TODO: properly propagate exception upwards + result.settle(&mut promise, global_this); if let Some(resolver) = (*this).resolver.as_ref() { // IntrusiveRc holds a live ref; request_completed mutates pending_requests counter only. (*resolver.as_ptr()).request_completed(); @@ -1863,6 +1886,57 @@ impl DNSLookup { } } +/// The converted answer for one global, shared by every waiter of a +/// pending-cache entry on that global. A conversion that threw is turned into +/// its exception value *once* (the first `reject(Err(Thrown))` would take it +/// off the VM and leave nothing for the next waiter); a termination settles +/// nobody. +#[derive(Clone, Copy)] +pub(crate) enum Outcome { + Value(JSValue), + Error(JSValue), + Terminated, +} + +impl Outcome { + pub(crate) fn of(global: &JSGlobalObject, result: JsResult) -> Outcome { + match result { + Ok(v) => Outcome::Value(v), + Err(bun_jsc::JsError::Terminated) => Outcome::Terminated, + Err(bun_jsc::JsError::OutOfMemory) => { + Outcome::Error(global.create_out_of_memory_error()) + } + Err(bun_jsc::JsError::Thrown) => match global.try_take_exception() { + Some(e) if e.is_termination_exception() => Outcome::Terminated, + Some(e) => Outcome::Error(e.to_error().unwrap_or(e)), + None => Outcome::Terminated, + }, + } + } + + /// Each waiter's completion may allocate; keep the shared value alive across them. + #[inline] + fn keep_alive(&self) { + if let Outcome::Value(v) | Outcome::Error(v) = self { + v.ensure_still_alive(); + } + } + + fn settle(self, promise: &mut JSPromiseStrong, global: &JSGlobalObject) { + let _guard = VirtualMachine::get().enter_event_loop_scope(); + let _ = match self { + Outcome::Value(v) => promise.resolve(global, v), + Outcome::Error(e) => promise.reject(global, Ok(e)), + Outcome::Terminated => return, + }; + } +} + +#[inline] +fn keep_alive(outcome: &Outcome) { + outcome.keep_alive(); +} + impl Drop for DNSLookup { fn drop(&mut self) { bun_output::scoped_log!(DNSLookup, "deinit"); @@ -1901,10 +1975,7 @@ impl Drop for GlobalData { // `Resolver::deinit` ends with `heap::take(this)`, which is wrong for a // value field — open-code the channel teardown so the c-ares state // frees when this box drops in `deinit_runtime_state`. - if let Some(channel) = self.resolver.channel.take() { - // SAFETY: `channel` is the live handle from `ares_init_options`, owned by this resolver. - unsafe { c_ares::Channel::destroy(channel) }; - } + self.resolver.destroy_channel(); } } @@ -1917,15 +1988,62 @@ impl Resolver { /// `DNSLookup::global_this` (to enqueue the rejection task) and the hive /// `FilePoll` (to unregister it from the loop). Running this after either /// is freed is a UAF (Node `test-worker-dns-terminate.js`). - pub(crate) fn close_channel_for_terminate(&self) { - if let Some(channel) = self.channel.take() { - // SAFETY: `channel` is the live handle from `ares_init_options`, owned by this resolver. - unsafe { c_ares::Channel::destroy(channel) }; + /// Windows: `uv_getaddrinfo` requests are uv *requests* on this thread's + /// loop, which the teardown drains before closing the loop; cancel the ones + /// still in flight so that drain is prompt (each completes through its + /// callback with UV_ECANCELED against the still-live VM). + #[cfg(windows)] + pub(crate) fn cancel_pending_uv_requests_for_teardown(&self) { + // SAFETY: JS thread; no other borrow of the cache is live during the + // stop phase (completions run later, from the loop drain). + let cache = unsafe { self.pending_host_cache_native.get_mut() }; + let mut set = cache.used.iter_set(); + while let Some(index) = set.next() { + // SAFETY: a set slot is an initialised `PendingCacheKey`; JS thread. + let lookup = unsafe { (*cache.ptr_at(index)).lookup }; + if lookup.is_null() { + continue; + } + // SAFETY: `lookup` is the live boxed request until its completion + // callback removes it from the cache. + unsafe { + if let get_addr_info_request::Backend::Libc(l) = &mut (*lookup).backend { + let _ = libuv::uv_cancel(core::ptr::from_mut(&mut l.uv).cast()); + } + } } + } + + /// `Stopped` if a channel was open (its pending queries just failed with + /// `ARES_EDESTRUCTION` into their callbacks). + /// + /// # Safety + /// `this` is a live resolver. It may be freed by the time this returns (a + /// failing query can drop the last reference); the caller touches nothing + /// of it afterwards. + pub(crate) unsafe fn close_channel_for_terminate( + this: *mut Self, + ) -> bun_jsc::virtual_machine::SweepResult { + use bun_jsc::virtual_machine::SweepResult; + // Failing the pending queries releases their refs on this resolver from + // inside `ares_destroy`; hold one so it outlives its own channel close. + // SAFETY: fn contract. + unsafe { (*this).ref_() }; + // SAFETY: alive under the ref just taken. + let result = if unsafe { (*this).destroy_channel() } { + SweepResult::Stopped + } else { + SweepResult::Idle + }; // `GetAddrInfoRequest`'s EDESTRUCTION path does not call // `request_completed()`, so the c-ares timeout timer (and its +1 ref on // this resolver plus the uws active-handle bump) can still be linked. - self.remove_timer(); + // SAFETY: as above; then release our ref (may free `this`). + unsafe { + (*this).remove_timer(); + Self::deref(this); + } + result } } @@ -2928,10 +3046,18 @@ pub mod internal { bstr::BStr::new(host.map(|h| h.as_bytes()).unwrap_or(b"")) ); // schedule the request to be executed on the work pool + run_on_work_pool(req); + Some(req) + } + + /// getaddrinfo() on the work pool; the result reaches every waiter through + /// the global cache, whichever thread asked. Also how a lookup whose + /// per-thread mDNSResponder connection went away with its thread is + /// finished (see `SharedConnection::close_for_terminate`). + pub(super) fn run_on_work_pool(req: *mut Request) { let _ = bun_threading::work_pool::WorkPool::go(SendPtr(req), |r: SendPtr| { work_pool_callback(r.0) }); - Some(req) } #[host_fn] @@ -3865,9 +3991,7 @@ impl Resolver { // SAFETY: `this` is the heap allocation from `init()`; refcount has hit // zero (sole caller is `Self::deref`), so we hold exclusive ownership. unsafe { - if let Some(channel) = (*this).channel.take() { - c_ares::Channel::destroy(channel); - } + (*this).destroy_channel(); drop(bun_core::heap::take(this)); } } @@ -4185,30 +4309,30 @@ impl Resolver { unsafe { let mut pending = (*key.lookup).head.next; let mut prev_global = (*key.lookup).head.global_this(); - let mut array = (*addr) - .to_js_response(prev_global, T::TYPE_NAME) - .unwrap_or(JSValue::ZERO); // TODO: properly propagate exception upwards + let mut array = Outcome::of( + prev_global, + (*addr).to_js_response(prev_global, T::TYPE_NAME), + ); // SAFETY: addr is the c-ares-allocated reply; freed once after all consumers run. let _free_addr = scopeguard::guard(addr, |a| T::destroy(a)); - array.ensure_still_alive(); + keep_alive(&array); CAresLookup::::on_complete(ptr::addr_of_mut!((*key.lookup).head), array); drop(bun_core::heap::take(key.lookup)); - array.ensure_still_alive(); + keep_alive(&array); while let Some(value) = pending { let new_global = (*value.as_ptr()).global_this(); if !core::ptr::eq(prev_global, new_global) { - array = (*addr) - .to_js_response(new_global, T::TYPE_NAME) - .unwrap_or(JSValue::ZERO); // TODO: properly propagate exception upwards + array = + Outcome::of(new_global, (*addr).to_js_response(new_global, T::TYPE_NAME)); prev_global = new_global; } pending = (*value.as_ptr()).next; - array.ensure_still_alive(); + keep_alive(&array); CAresLookup::::on_complete(value.as_ptr(), array); - array.ensure_still_alive(); + keep_alive(&array); } } } @@ -4251,29 +4375,33 @@ impl Resolver { unsafe { let mut pending = (*key.lookup).head.next; let mut prev_global = (*key.lookup).head.global_this(); - let mut array = super::cares_jsc::addr_info_to_js_array(&mut *addr, prev_global) - .unwrap_or(JSValue::ZERO); // TODO: properly propagate exception upwards + let mut array = Outcome::of( + prev_global, + super::cares_jsc::addr_info_to_js_array(&mut *addr, prev_global), + ); // SAFETY: addr is the c-ares-allocated AddrInfo; freed once after all consumers run. // Move the raw pointer into the guard so the loop body can keep borrowing `*addr`. let _free_addr = scopeguard::guard(addr, |a| c_ares::AddrInfo::destroy(a)); - array.ensure_still_alive(); + keep_alive(&array); DNSLookup::on_complete_with_array(ptr::addr_of_mut!((*key.lookup).head), array); drop(bun_core::heap::take(key.lookup)); - array.ensure_still_alive(); + keep_alive(&array); while let Some(value) = pending { let new_global = (*value.as_ptr()).global_this(); if !core::ptr::eq(prev_global, new_global) { - array = super::cares_jsc::addr_info_to_js_array(&mut *addr, new_global) - .unwrap_or(JSValue::ZERO); // TODO: properly propagate exception upwards + array = Outcome::of( + new_global, + super::cares_jsc::addr_info_to_js_array(&mut *addr, new_global), + ); prev_global = new_global; } pending = (*value.as_ptr()).next; - array.ensure_still_alive(); + keep_alive(&array); DNSLookup::on_complete_with_array(value.as_ptr(), array); - array.ensure_still_alive(); + keep_alive(&array); } } } @@ -4291,11 +4419,10 @@ impl Resolver { // SAFETY: `self` is the live heap allocation; ref_scope keeps count > 0 across re-entrant callbacks. let _g = unsafe { Self::ref_scope(self.as_ctx_ptr()) }; - let mut array: JSValue = match super::options_jsc::result_any_to_js(result, global_object) - .unwrap_or(None) + let mut array: Outcome = match super::options_jsc::result_any_to_js(result, global_object) + .transpose() { - // TODO: properly propagate exception upwards - Some(a) => a, + Some(a) => Outcome::of(global_object, a), None => { // SAFETY: `key.lookup` is the heap-allocated request stored in the // pending-cache slot; consumed via `heap::take` below. @@ -4326,25 +4453,28 @@ impl Resolver { let mut prev_global = (*key.lookup).head.global_this(); { - array.ensure_still_alive(); + keep_alive(&array); DNSLookup::on_complete_with_array(ptr::addr_of_mut!((*key.lookup).head), array); drop(bun_core::heap::take(key.lookup)); - array.ensure_still_alive(); + keep_alive(&array); } while let Some(value) = pending { let new_global = (*value.as_ptr()).global_this(); pending = (*value.as_ptr()).next; if !core::ptr::eq(prev_global, new_global) { - array = super::options_jsc::result_any_to_js(result, new_global) - .unwrap_or(None) - .unwrap(); // TODO: properly propagate exception upwards + // Non-null addrinfo (checked above): never `None`. + array = Outcome::of( + new_global, + super::options_jsc::result_any_to_js(result, new_global) + .map(|a| a.expect("addrinfo present")), + ); prev_global = new_global; } - array.ensure_still_alive(); + keep_alive(&array); DNSLookup::on_complete_with_array(value.as_ptr(), array); - array.ensure_still_alive(); + keep_alive(&array); } } } @@ -4390,26 +4520,30 @@ impl Resolver { // The callback need not and should not attempt to free the memory // pointed to by hostent; the ares library will free it when the // callback returns. - let mut array = super::cares_jsc::hostent_to_js_response(&mut *addr, prev_global, b"") - .unwrap_or(JSValue::ZERO); // TODO: properly propagate exception upwards - array.ensure_still_alive(); + let mut array = Outcome::of( + prev_global, + super::cares_jsc::hostent_to_js_response(&mut *addr, prev_global, b""), + ); + keep_alive(&array); CAresReverse::on_complete(ptr::addr_of_mut!((*key.lookup).head), array); drop(bun_core::heap::take(key.lookup)); - array.ensure_still_alive(); + keep_alive(&array); while let Some(value) = pending { let new_global = (*value.as_ptr()).global_this(); if !core::ptr::eq(prev_global, new_global) { - array = super::cares_jsc::hostent_to_js_response(&mut *addr, new_global, b"") - .unwrap_or(JSValue::ZERO); // TODO: properly propagate exception upwards + array = Outcome::of( + new_global, + super::cares_jsc::hostent_to_js_response(&mut *addr, new_global, b""), + ); prev_global = new_global; } pending = (*value.as_ptr()).next; - array.ensure_still_alive(); + keep_alive(&array); CAresReverse::on_complete(value.as_ptr(), array); - array.ensure_still_alive(); + keep_alive(&array); } } } @@ -4453,26 +4587,30 @@ impl Resolver { let mut pending = (*key.lookup).head.next; let mut prev_global = (*key.lookup).head.global_this(); - let mut array = super::cares_jsc::nameinfo_to_js_response(&mut name_info, prev_global) - .unwrap_or(JSValue::ZERO); // TODO: properly propagate exception upwards - array.ensure_still_alive(); + let mut array = Outcome::of( + prev_global, + super::cares_jsc::nameinfo_to_js_response(&mut name_info, prev_global), + ); + keep_alive(&array); CAresNameInfo::on_complete(ptr::addr_of_mut!((*key.lookup).head), array); drop(bun_core::heap::take(key.lookup)); - array.ensure_still_alive(); + keep_alive(&array); while let Some(value) = pending { let new_global = (*value.as_ptr()).global_this(); if !core::ptr::eq(prev_global, new_global) { - array = super::cares_jsc::nameinfo_to_js_response(&mut name_info, new_global) - .unwrap_or(JSValue::ZERO); // TODO: properly propagate exception upwards + array = Outcome::of( + new_global, + super::cares_jsc::nameinfo_to_js_response(&mut name_info, new_global), + ); prev_global = new_global; } pending = (*value.as_ptr()).next; - array.ensure_still_alive(); + keep_alive(&array); CAresNameInfo::on_complete(value.as_ptr(), array); - array.ensure_still_alive(); + keep_alive(&array); } } } @@ -5154,6 +5292,27 @@ impl c_ares::ChannelContainer for Resolver { #[inline] fn set_channel(&self, channel: *mut c_ares::Channel) { self.channel.set(Some(channel)); + // A live channel has sockets, timers and queries in flight whose + // callbacks need this VM: the stop phase closes it (any resolver, not + // just the VM-global one) if nobody did before. Unregistered in + // `destroy_channel`. + crate::jsc_hooks::ActiveHandle::DnsResolver(core::ptr::NonNull::from(self)).register(); + } +} + +impl Resolver { + /// The one place a channel is torn down: `ares_destroy` fails every + /// pending query with `ARES_EDESTRUCTION` into its callback (releasing the + /// request's ref on this resolver) and closes the channel's sockets. + /// Returns whether there was a channel. + fn destroy_channel(&self) -> bool { + let Some(channel) = self.channel.take() else { + return false; + }; + crate::jsc_hooks::ActiveHandle::DnsResolver(core::ptr::NonNull::from(self)).unregister(); + // SAFETY: `channel` is the live handle from `ares_init_options`, owned by this resolver. + unsafe { c_ares::Channel::destroy(channel) }; + true } } diff --git a/src/runtime/dns_jsc/dns_sd.rs b/src/runtime/dns_jsc/dns_sd.rs index 227aa02c73a3..9edaa8d6d1ac 100644 --- a/src/runtime/dns_jsc/dns_sd.rs +++ b/src/runtime/dns_jsc/dns_sd.rs @@ -659,9 +659,21 @@ impl SharedConnection { let Some(conn) = (unsafe { this.as_mut() }) else { return; }; - // Subordinates are failed (deallocating them) before the parent. + // Subordinates are dealt with (deallocating them) before the parent. while let Some(inf) = conn.inflight.pop() { - Self::finish(inf, Some(ERR_DEFUNCT_CONNECTION)); + match inf { + // A connect-path lookup lives in the process-wide cache and may + // have waiters on other threads (and its outcome is cached): this + // thread going away is not an answer. Finish it on the work pool. + Inflight::Internal(req) => { + // SAFETY: `inf` is a live heap request just removed from `inflight`; + // FFI releases this thread's subordinate for it. + unsafe { DNSServiceRefDeallocate(inf.query().sd_ref) }; + internal::run_on_work_pool(req); + } + // A dns.lookup() from this thread's script: only this VM waits on it. + Inflight::Jsc(_) => Self::finish(inf, Some(ERR_DEFUNCT_CONNECTION)), + } } // SAFETY: `this` is detached and drained. unsafe { Self::destroy(this) }; diff --git a/src/runtime/ffi/FFIObject.rs b/src/runtime/ffi/FFIObject.rs index 0c48941d71e4..2b983cd21755 100644 --- a/src/runtime/ffi/FFIObject.rs +++ b/src/runtime/ffi/FFIObject.rs @@ -834,7 +834,7 @@ mod fields { let mut iter = callframe.arguments().iter(); let name = eat_zig_string(global, &mut iter)?; let object = eat_required(global, &mut iter)?; - Ok(FfiImpl::open(global, name, object)) + FfiImpl::open(global, name, object) } // callback → FFI::callback(global, JSValue, JSValue) -> JsResult @@ -852,7 +852,7 @@ mod fields { ) -> JsResult { let mut iter = callframe.arguments().iter(); let object = eat_required(global, &mut iter)?; - Ok(FfiImpl::link_symbols(global, object)) + FfiImpl::link_symbols(global, object) } // toBuffer → to_buffer(global, JSValue, ?JSValue×4) -> JsResult @@ -886,7 +886,7 @@ mod fields { ) -> JsResult { let mut iter = callframe.arguments().iter(); let callback = eat_required(global, &mut iter)?; - Ok(FfiImpl::close_jsc_callback(global, callback)) + FfiImpl::close_jsc_callback(global, callback) } pub(super) fn cfunction(global: &JSGlobalObject, callframe: &CallFrame) -> JsResult { diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 5e957df3b328..e43b77f8e064 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -1270,13 +1270,16 @@ impl FFI { Ok(js_object) } - pub fn close_jsc_callback(_global_this: &JSGlobalObject, callback: JSValue) -> JSValue { + pub fn close_jsc_callback( + _global_this: &JSGlobalObject, + callback: JSValue, + ) -> JsResult { unsafe extern "C" { fn Bun__JSCFFICallbackClose(callback: JSValue); } // SAFETY: thin FFI wrapper; the C++ side type-checks the cell (jsDynamicCast) before use. unsafe { Bun__JSCFFICallbackClose(callback) }; - JSValue::UNDEFINED + Ok(JSValue::UNDEFINED) } pub fn callback( @@ -1296,11 +1299,7 @@ impl FFI { let mut function = Function::default(); let func = &mut function; - if let Some(val) = generate_symbol_for_function(global_this, interface, func) - .unwrap_or_else(|_| { - Some(ZigString::init(b"Out of memory").to_error_instance(global_this)) - }) - { + if let Some(val) = generate_symbol_for_function(global_this, interface, func)? { return Ok(val); } @@ -1332,11 +1331,14 @@ impl FFI { ) }; if cb.is_empty() { - return Ok(if global_this.has_exception() { - global_this.take_error(JsError::Thrown) - } else { + // An exception left by the constructor (OOM, or a termination + // request landing in it) is the caller's, not a value. + if global_this.has_exception() { + return Err(JsError::Thrown); + } + return Ok( ZigString::init(b"Failed to create FFI callback").to_error_instance(global_this) - }); + ); } Ok(cb) } @@ -1363,24 +1365,22 @@ impl FFI { self.functions.with_mut(|f| f.clear_retaining_capacity()); } - pub fn print_callback(global: &JSGlobalObject, object: JSValue) -> JSValue { + pub fn print_callback(global: &JSGlobalObject, object: JSValue) -> JsResult { jsc::mark_binding(); if object.is_empty_or_undefined_or_null() || !object.is_object() { - return global.to_invalid_arguments(format_args!("Expected an object")); + return Ok(global.to_invalid_arguments(format_args!("Expected an object"))); } let mut function = Function::default(); - if let Some(val) = generate_symbol_for_function(global, object, &mut function) - .unwrap_or_else(|_| Some(ZigString::init(b"Out of memory").to_error_instance(global))) - { - return val; + if let Some(val) = generate_symbol_for_function(global, object, &mut function)? { + return Ok(val); } let _ = function; let text: &[u8] = b"// bun:ffi callbacks are compiled by JavaScriptCore (no C source is generated)\n"; - jsc::bun_string_jsc::create_utf8_for_js(global, text).unwrap_or(JSValue::ZERO) + jsc::bun_string_jsc::create_utf8_for_js(global, text) } pub fn print( @@ -1390,7 +1390,7 @@ impl FFI { ) -> JsResult { if let Some(is_callback) = is_callback_val { if is_callback.to_boolean() { - return Ok(Self::print_callback(global, object)); + return Self::print_callback(global, object); } } @@ -1449,16 +1449,16 @@ impl FFI { global: &JSGlobalObject, name_str: ZigString, object_value: JSValue, - ) -> JSValue { + ) -> JsResult { jsc::mark_binding(); let vm = jsc::VirtualMachineRef::get(); let name_slice = name_str.to_slice(); if object_value.is_empty_or_undefined_or_null() { - return invalid_options_arg(global); + return Ok(invalid_options_arg(global)); } let Some(object) = object_value.get_object() else { - return invalid_options_arg(global); + return Ok(invalid_options_arg(global)); }; let mut filepath_buf = bun_paths::path_buffer_pool::get(); @@ -1498,19 +1498,17 @@ impl FFI { }; if name.is_empty() { - return global.to_invalid_arguments(format_args!("Invalid library name")); + return Ok(global.to_invalid_arguments(format_args!("Invalid library name"))); } let mut symbols = StringArrayHashMap::::default(); // SAFETY: `get_object()` returned a non-null `*mut JSObject`; `object_value` keeps it alive. - if let Some(val) = generate_symbols(global, &mut symbols, unsafe { &*object }) - .unwrap_or(Some(JSValue::ZERO)) - { + if let Some(val) = generate_symbols(global, &mut symbols, unsafe { &*object })? { // an error while validating symbols - return val; + return Ok(val); } if symbols.len() == 0 { - return global.to_invalid_arguments(format_args!("Expected at least one symbol")); + return Ok(global.to_invalid_arguments(format_args!("Expected at least one symbol"))); } let dylib: bun_sys::DynLib = 'brk: { @@ -1540,7 +1538,7 @@ impl FFI { syscall: bun_core::String::clone_utf8(b"dlopen").into(), ..Default::default() }; - return system_error.to_error_instance(global); + return Ok(system_error.to_error_instance(global)); } } } @@ -1574,7 +1572,7 @@ impl FFI { dylib.close(); // SAFETY: lib_ptr is the live, JS-owned FFI allocation (from into_raw). unsafe { &*lib_ptr }.do_close(); - return ret; + return Ok(ret); }; function.symbol_from_dynamic_library = Some(resolved_symbol); @@ -1584,7 +1582,7 @@ impl FFI { dylib.close(); // SAFETY: lib_ptr is the live, JS-owned FFI allocation (from into_raw). unsafe { &*lib_ptr }.do_close(); - return err; + return Ok(err); } let target = function .symbol_from_dynamic_library @@ -1592,14 +1590,15 @@ impl FFI { let str = ZigString::init(function_name.as_bytes()); let cb = create_jsc_ffi_function(global, &str, function, target, js_object); if cb.is_empty() { + // An exception the constructor left pending is the caller's. let ret = if global.has_exception() { - global.take_error(JsError::Thrown) + Err(JsError::Thrown) } else { - global.to_invalid_arguments(format_args!( + Ok(global.to_invalid_arguments(format_args!( "Failed to create FFI function for symbol \"{}\" in \"{}\"", BStr::new(function_name.as_bytes()), BStr::new(name) - )) + ))) }; dylib.close(); // SAFETY: lib_ptr is the live, JS-owned FFI allocation (from into_raw). @@ -1614,7 +1613,7 @@ impl FFI { lib_ref.functions.set(symbols); lib_ref.dylib.set(Some(dylib)); symbols_value_set_cached(js_object, global, obj); - js_object + Ok(js_object) } #[bun_jsc::host_fn(getter)] @@ -1623,26 +1622,27 @@ impl FFI { JSValue::UNDEFINED } - pub(crate) fn link_symbols(global: &JSGlobalObject, object_value: JSValue) -> JSValue { + pub(crate) fn link_symbols( + global: &JSGlobalObject, + object_value: JSValue, + ) -> JsResult { jsc::mark_binding(); if object_value.is_empty_or_undefined_or_null() { - return invalid_options_arg(global); + return Ok(invalid_options_arg(global)); } let Some(object) = object_value.get_object() else { - return invalid_options_arg(global); + return Ok(invalid_options_arg(global)); }; let mut symbols = StringArrayHashMap::::default(); // SAFETY: `get_object()` returned a non-null `*mut JSObject`; `object_value` keeps it alive. - if let Some(val) = generate_symbols(global, &mut symbols, unsafe { &*object }) - .unwrap_or(Some(JSValue::ZERO)) - { + if let Some(val) = generate_symbols(global, &mut symbols, unsafe { &*object })? { // an error while validating symbols - return val; + return Ok(val); } if symbols.len() == 0 { - return global.to_invalid_arguments(format_args!("Expected at least one symbol")); + return Ok(global.to_invalid_arguments(format_args!("Expected at least one symbol"))); } let obj = JSValue::create_empty_object(global, symbols.len()); @@ -1664,25 +1664,26 @@ impl FFI { )); // SAFETY: lib_ptr is the live, JS-owned FFI allocation (from into_raw). unsafe { &*lib_ptr }.do_close(); - return ret; + return Ok(ret); } if let Some(err) = function.reject_napi_types_error(global) { // SAFETY: lib_ptr is the live, JS-owned FFI allocation (from into_raw). unsafe { &*lib_ptr }.do_close(); - return err; + return Ok(err); } let target = function.symbol_from_dynamic_library.expect("checked above"); let name = ZigString::init(function_name.as_bytes()); let cb = create_jsc_ffi_function(global, &name, function, target, js_object); if cb.is_empty() { + // An exception the constructor left pending is the caller's. let err = if global.has_exception() { - global.take_error(JsError::Thrown) + Err(JsError::Thrown) } else { - global.to_invalid_arguments(format_args!( + Ok(global.to_invalid_arguments(format_args!( "Failed to create FFI function for symbol \"{}\"", BStr::new(function_name.as_bytes()) - )) + ))) }; // SAFETY: lib_ptr is the live, JS-owned FFI allocation (from into_raw). unsafe { &*lib_ptr }.do_close(); @@ -1694,7 +1695,7 @@ impl FFI { // SAFETY: lib_ptr is the live, JS-owned FFI allocation (from into_raw). unsafe { &*lib_ptr }.functions.set(symbols); symbols_value_set_cached(js_object, global, obj); - js_object + Ok(js_object) } pub fn create_cfunction( diff --git a/src/runtime/image/Image.rs b/src/runtime/image/Image.rs index 9ee13de94198..8949749ace9a 100644 --- a/src/runtime/image/Image.rs +++ b/src/runtime/image/Image.rs @@ -6,7 +6,7 @@ //! write one slot of `Pipeline` and return `this` — there is no op list, so //! calling a setter twice overwrites. The actual decode → transform → encode //! work happens off-thread when a terminal (`bytes`/`buffer`/`blob`/ -//! `toBase64`/`metadata`) is awaited, via `jsc.ConcurrentPromiseTask`. +//! `toBase64`/`metadata`) is awaited, as a `bun_jsc::Job` (`PipelineTask`). use core::cell::Cell; use core::mem; @@ -21,7 +21,6 @@ use bun_core::ZBox; use bun_core::base64; use bun_core::zstr; use bun_core::{ZStr, strings}; -use bun_jsc::concurrent_promise_task::{ConcurrentPromiseTask, ConcurrentPromiseTaskContext}; use bun_jsc::{ self as jsc, ArrayBuffer, CallFrame, JSGlobalObject, JSPromise, JSValue, JsCell, JsClass as _, JsRef, JsResult, StringJsc as _, Strong, SysErrorJsc as _, @@ -719,7 +718,7 @@ impl Image { &self, this_value: JSValue, _global: &JSGlobalObject, - ) -> Result { + ) -> Result<(Input, Pin), PinError> { match self.source.get() { Source::JsBuffer => { let Some(v) = js::source_js_get_cached(this_value) else { @@ -750,10 +749,13 @@ impl Image { // SAFETY: classifier guarantees `ptr[0..len]` is // valid for the duration of this call (JS thread). let copied = unsafe { bun_core::ffi::slice(ptr, len) }.to_vec(); - Ok(Input { - copied: Some(copied), - ..Default::default() - }) + Ok(( + Input { + copied: Some(copied), + ..Default::default() + }, + Pin::NONE, + )) } } // Oversize/Wasteful/DataView/JSArrayBuffer: pinned by the @@ -767,14 +769,16 @@ impl Image { unsafe { JSC__JSValue__unpinArrayBuffer(v) }; Err(PinError::Detached) } else { - // SAFETY: pinned for the lifetime of the task; - // unpinned in `then()` via `Input::release()`. + // SAFETY: pinned until the returned `Pin` drops (with the job's + // Js side, or the sync caller's scope). let bytes = unsafe { bun_core::ffi::slice(ptr, len) }; - Ok(Input { - bytes: bun_ptr::RawSlice::new(bytes), - pinned: v, - ..Default::default() - }) + Ok(( + Input { + bytes: bun_ptr::RawSlice::new(bytes), + ..Default::default() + }, + Pin(v), + )) } } _ => unreachable!(), @@ -782,14 +786,20 @@ impl Image { } // SAFETY: `Owned` bytes outlive the task because `this_ref` is held // Strong while pending_tasks > 0 (see `schedule()`). - Source::Owned(b) => Ok(Input { - bytes: bun_ptr::RawSlice::new(b.as_slice()), - ..Default::default() - }), - Source::Path(p) => Ok(Input { - path: Some(std::ptr::from_ref::(p.as_zstr())), - ..Default::default() - }), + Source::Owned(b) => Ok(( + Input { + bytes: bun_ptr::RawSlice::new(b.as_slice()), + ..Default::default() + }, + Pin::NONE, + )), + Source::Path(p) => Ok(( + Input { + path: Some(std::ptr::from_ref::(p.as_zstr())), + ..Default::default() + }, + Pin::NONE, + )), // schedule() peels this off before pin_for_task is reached. Source::Blob(_) => unreachable!(), } @@ -1111,11 +1121,9 @@ impl Image { if matches!(self.source.get(), Source::Blob(_)) { return BlobReadChain::start(self, global, this_value, kind, deliver); } - let input = match self.pin_for_task(this_value, global) { + let (input, pin) = match self.pin_for_task(this_value, global) { Ok(i) => i, Err(PinError::Detached) => { - // `deliver` may own a Strong; the task that would have freed it - // in Drop is never created on this branch. drop(deliver); return Ok(JSPromise::rejected_promise( global, @@ -1128,35 +1136,27 @@ impl Image { .as_value(global)); } }; - let job = Box::new(PipelineTask { - image: std::ptr::from_ref::(self), - global, - // Struct copy — the worker reads its own snapshot so further chained - // calls on the JS side between schedule and completion don't race. + let work = PipelineTask { pipeline: self.pipeline.get(), input, kind, - deliver, max_pixels: self.max_pixels, auto_orient: self.auto_orient, result: TaskResult::Err(codecs::Error::DecodeFailed), - }); - // First in-flight task ⇒ hold a Strong ref to the wrapper so GC can't - // collect it (and its sourceJS slot, and the pinned ArrayBuffer) until - // `then()` drops the count back to 0. - if self.pending_tasks.get() == 0 { - self.this_ref.with_mut(|r| r.set_strong(this_value, global)); - } - self.pending_tasks.set(self.pending_tasks.get() + 1); - let task = ConcurrentPromiseTask::>::create_on_js_thread(global, job); - let promise_value = task.promise.value(); - // Ownership transfers to the WorkPool / event-loop dispatch - // (`task_tag::AsyncImageTask` → `run_from_js` → `destroy`). - let raw = bun_core::heap::into_raw(task); - // SAFETY: `raw` is freshly leaked; `schedule()` only writes the - // intrusive `task` field into the work-pool queue. The worker thread - // touches `ctx`/`task` only; `promise` was read above on this thread. - unsafe { (*raw).schedule() }; + }; + let cx = global.js_thread(); + let promise = jsc::JSPromiseStrong::init(global); + let promise_value = promise.value(); + jsc::Job::::schedule( + &cx, + work, + PipelineJs { + promise, + deliver, + _pin: pin, + image: PendingTask::new(self, this_value, global), + }, + ); Ok(promise_value) } @@ -1204,38 +1204,26 @@ impl Image { return Err(global.throw(format_args!("{REFUSE}"))); } } - let input = match self.pin_for_task(this_value, global) { + let (input, _pin) = match self.pin_for_task(this_value, global) { Ok(i) => i, Err(PinError::Detached) => { return Err(global.throw(format_args!("Image: source ArrayBuffer was detached"))); } }; - // The `input` release is hoisted below — `input` - // moves into `task`, and `run()` is sync with no early returns, so we - // release via `task.input` after the result is extracted. - // Cleanup must not run on this stack - // temporary (only `then()` does it). `Drop` here would - // underflow `pending_tasks` and downgrade `this_ref`, so suppress it. - let mut task = mem::ManuallyDrop::new(PipelineTask { - image: std::ptr::from_ref::(self), - global, + // `_pin` unpins at scope exit, after `run()` is done with the bytes. + let mut task = PipelineTask { pipeline: self.pipeline.get(), input, kind: Kind::Encode(self.pipeline.get().output), - deliver: Deliver::Uint8Array, max_pixels: self.max_pixels, auto_orient: self.auto_orient, result: TaskResult::Err(codecs::Error::DecodeFailed), - }); + }; task.run(); - // Reshaped for borrowck — move `result` out via `replace` - // since `task` is behind `ManuallyDrop` deref. let result = mem::replace( &mut task.result, TaskResult::Err(codecs::Error::DecodeFailed), ); - // Release `input` (see hoisting note above). - mem::take(&mut task.input).release(); match result { TaskResult::Encoded { out, format, w, h } => { self.last_width.set(i32::try_from(w).expect("int cast")); @@ -1399,36 +1387,97 @@ impl<'a> ReadBytesHandler for BlobReadChain<'a> { } } -/// `jsc.ConcurrentPromiseTask(PipelineTask)` — the heap object the event-loop -/// dispatch sees (`task_tag::AsyncImageTask`). -pub type AsyncImageTask<'a> = ConcurrentPromiseTask<'a, PipelineTask<'a>>; - -impl<'a> ConcurrentPromiseTaskContext for PipelineTask<'a> { - const TASK_TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::AsyncImageTask; - #[inline] - fn run(&mut self) { - PipelineTask::run(self) - } - #[inline] - fn then(&mut self, promise: &mut JSPromise) -> Result<(), jsc::JsTerminated> { - PipelineTask::then(self, promise) - } -} - -pub struct PipelineTask<'a> { - image: *const Image, - global: &'a JSGlobalObject, +/// The pool-side work of one `Image` operation: decode → pipeline → encode +/// (or probe). Also run synchronously by `encode_for_body`. +pub struct PipelineTask { pipeline: Pipeline, input: Input, kind: Kind, - deliver: Deliver, max_pixels: u64, auto_orient: bool, result: TaskResult, } +// SAFETY: `input` borrows bytes that are pinned (`Pin`) or owned by the Image +// the job's Js side keeps alive; read only under the pool borrow. The rest is owned. +unsafe impl Send for PipelineTask {} + +/// The JS-thread half of a scheduled `PipelineTask`. +#[derive(bun_jsc::JsAffine)] +pub struct PipelineJs { + promise: jsc::JSPromiseStrong, + deliver: Deliver, + _pin: Pin, + image: PendingTask, +} + +/// An ArrayBuffer pinned by `JSC__JSValue__borrowBytesForOffThread` (mode 2) +/// so user code cannot transfer/detach it while the pool reads; unpinned on drop. +pub struct Pin(JSValue); +// SAFETY: a pin on a heap cell; gone with the heap. +unsafe impl bun_jsc::job::JsAffine for Pin {} +impl Pin { + const NONE: Pin = Pin(JSValue::ZERO); +} +impl Drop for Pin { + fn drop(&mut self) { + if !self.0.is_empty() { + // SAFETY: JS thread; `self.0` was pinned by the helper. + unsafe { JSC__JSValue__unpinArrayBuffer(self.0) }; + } + } +} + +/// One pending operation's hold on its `Image`: keeps the wrapper Strong while +/// any are pending, and lets the completion reach the `Image` (JS thread). +pub struct PendingTask(jsc::JsPtr); +// SAFETY: the Image is its wrapper's m_ctx; the Strong we hold keeps that alive. +unsafe impl bun_jsc::job::JsAffine for PendingTask {} +impl PendingTask { + fn new(image: &Image, this_value: JSValue, global: &JSGlobalObject) -> Self { + if image.pending_tasks.get() == 0 { + image + .this_ref + .with_mut(|r| r.set_strong(this_value, global)); + } + image.pending_tasks.set(image.pending_tasks.get() + 1); + // SAFETY: see the JsAffine note. + Self(unsafe { jsc::JsPtr::new(core::ptr::NonNull::from(image)) }) + } + fn image<'s>(&'s self, _cx: &jsc::JsThread<'_>) -> &'s Image { + // SAFETY: JS thread (token); wrapper alive (Strong while pending > 0); + // shared deref — mutation goes through `Cell`/`JsCell` (R-2). + unsafe { &*self.0.as_ptr() } + } +} +impl Drop for PendingTask { + fn drop(&mut self) { + // JS thread (a job's Js side is only ever dropped there). + // SAFETY: as `image()`. + let image = unsafe { &*self.0.as_ptr() }; + image.pending_tasks.set(image.pending_tasks.get() - 1); + if image.pending_tasks.get() == 0 { + image.this_ref.with_mut(|r| r.downgrade()); + } + } +} -/// Bytes for the worker. `.pinned` is the JS ArrayBuffer/view to unpin in -/// `then()` — `.zero` for owned/path sources (nothing to unpin). +impl jsc::JobContext for PipelineTask { + type OffThread = Self; + type Js = PipelineJs; + fn run( + this: &mut Self, + _vm: &jsc::vm_handle::Borrow, + done: bun_jsc::Completion, + ) -> Option> { + this.run(); + Some(done) + } + fn then(this: Self, js: PipelineJs, cx: &jsc::JsThread<'_>) -> jsc::JsResult<()> { + Ok(PipelineTask::then(this, js, cx)?) + } +} + +/// Bytes for the worker: a pinned/owned slice, a copy, or a path to read there. pub struct Input { // Borrows pinned ArrayBuffer or `image.source.owned`; the owning `Image` // is held via BACKREF for the task's lifetime — `RawSlice` invariant. @@ -1436,10 +1485,7 @@ pub struct Input { // Borrows `image.source.path` (NUL-terminated); the owning `Image` is // held via BACKREF for the task's lifetime, same as `bytes` above. path: Option<*const ZStr>, - /// JS value to `unpinArrayBuffer` in `then()`. `.zero` for sources - /// with no ArrayBuffer to pin (Oversize TA, owned, path, copied). - pinned: JSValue, - /// Our own dupe of a FastTypedArray's bytes — freed in `then()`. + /// FastTypedArray inputs are tiny and GC-movable: copied instead of pinned. copied: Option>, } @@ -1448,7 +1494,6 @@ impl Default for Input { Self { bytes: bun_ptr::RawSlice::EMPTY, path: None, - pinned: JSValue::ZERO, copied: None, } } @@ -1461,16 +1506,9 @@ impl Input { } self.bytes.slice() } - fn release(mut self) { - if !self.pinned.is_empty() { - // SAFETY: JS thread; `pinned` was returned by - // `JSC__JSValue__borrowBytesForOffThread` with mode 2. - unsafe { JSC__JSValue__unpinArrayBuffer(self.pinned) }; - } - self.copied = None; - } } +#[derive(bun_jsc::JsAffine)] pub enum Deliver { Uint8Array, Buffer, @@ -1514,7 +1552,7 @@ pub enum TaskResult { IoErr(sys::Error), } -impl<'a> PipelineTask<'a> { +impl PipelineTask { /// Runs on a `WorkPool` thread. No JSC access. pub(crate) fn run(&mut self) { // `self.input` was prepared on the JS thread by `pin_for_task`: either a @@ -1731,23 +1769,18 @@ impl<'a> PipelineTask<'a> { }; } - /// Back on the JS thread. - pub(crate) fn then(&mut self, promise: &mut JSPromise) -> Result<(), jsc::JsTerminated> { - // `defer self.deinit()` → handled by `Drop for PipelineTask` when the - // owning `ConcurrentPromiseTask` Box is destroyed by the event-loop - // dispatch (`run_from_js` → `destroy`), immediately after this returns. - // JS thread again — release the per-task pin so user code can - // transfer/detach the source now. - // Reshaped for borrowck — `PipelineTask: Drop` forbids - // moving fields out by destructure; `mem::take`/`mem::replace` the - // owning fields into locals instead so `Drop` still runs on the husk. - mem::take(&mut self.input).release(); - let global = self.global; - // SAFETY: BACKREF; JS thread; wrapper kept alive by `this_ref` Strong. - // R-2: shared deref — mutation goes through `Cell`. - let image = unsafe { &*self.image }; + /// Back on the JS thread: publish dims, deliver the result. The pin and + /// the hold on the Image are released when `js` drops at the end. + pub(crate) fn then( + mut self, + mut js: PipelineJs, + cx: &jsc::JsThread<'_>, + ) -> Result<(), jsc::JsTerminated> { + let global = cx.global(); + let promise = js.promise.swap(); + let image = js.image.image(cx); // Stash final dims here (JS thread) — `run()` is on a WorkPool thread - // so writing `self.image.*` there would race the synchronous getters. + // so writing `image.*` there would race the synchronous getters. match &self.result { TaskResult::Encoded { w, h, .. } | TaskResult::Meta { w, h, .. } => { image.last_width.set(i32::try_from(*w).expect("int cast")); @@ -1755,8 +1788,6 @@ impl<'a> PipelineTask<'a> { } _ => {} } - // `Drop` forbids moving out of `self.result`; swap in a - // throwaway sentinel (`Err` is `Copy`) and match the owned local. let result = mem::replace( &mut self.result, TaskResult::Err(codecs::Error::UnknownFormat), @@ -1770,7 +1801,7 @@ impl<'a> PipelineTask<'a> { // SAFETY: `out.bytes` is a non-null fat pointer into a live // codec allocation; valid until `out.free` runs. let out_slice: &[u8] = unsafe { out.bytes.as_ref() }; - match &mut self.deliver { + match &mut js.deliver { // The codec's own allocation is handed straight to JS with the // codec's free as the finalizer — no dupe of the output. Deliver::Uint8Array => { @@ -1797,7 +1828,7 @@ impl<'a> PipelineTask<'a> { // createBufferWithCtx returns plain JSValue (its C++ side asserts // the no-throw contract), so the .uint8array catch is unmatched // here by construction, not omission. - Deliver::Buffer => promise.resolve( + Deliver::Buffer => promise.settle( global, // SAFETY: `out.bytes` is the codec-owned allocation whose // ownership transfers to JSC; `ctx` is null and `out.free` @@ -1871,13 +1902,16 @@ impl<'a> PipelineTask<'a> { // SAFETY: `out.bytes` is the codec-owned allocation whose // ownership transfers to JSC; `ctx` is null and `out.free` // ignores it. - let data = unsafe { + let data = match unsafe { JSValue::create_buffer_with_ctx( global, out.bytes, core::ptr::null_mut(), out.free, ) + } { + Ok(d) => d, + Err(e) => return promise.reject(global, Err(e)), }; // SAFETY: `bun_vm()` returns a non-null `*mut VirtualMachine` // valid for the JS thread; `ArgumentsSlice::init` wants `&`. @@ -2064,21 +2098,3 @@ fn apply_orientation( } Ok(()) } - -impl<'a> Drop for PipelineTask<'a> { - fn drop(&mut self) { - // Only reached from `then()` on the JS thread (the `encode_for_body` - // stack temporary is wrapped in `ManuallyDrop`), so the ref/count touch is safe without - // atomics. - // `self.deliver.deinit()` — `Strong` Drop on the `WriteDest` arm. - // SAFETY: `image` is a BACKREF kept alive by the wrapper's Strong - // `this_ref` while pending_tasks > 0; we are on the JS thread. - // R-2: shared deref — mutation goes through `Cell`/`JsCell`. - let image = unsafe { &*self.image }; - image.pending_tasks.set(image.pending_tasks.get() - 1); - if image.pending_tasks.get() == 0 { - image.this_ref.with_mut(|r| r.downgrade()); - } - // `bun.destroy(this)` — `Box` drop is the caller. - } -} diff --git a/src/runtime/image/mod.rs b/src/runtime/image/mod.rs index 9b7689dbd541..d6378384efc8 100644 --- a/src/runtime/image/mod.rs +++ b/src/runtime/image/mod.rs @@ -3,8 +3,8 @@ //! The pure-Rust codec dispatch (`codecs.rs`), per-format decoders/encoders //! (`codec_*.rs`), EXIF/quantize/thumbhash helpers, and the platform backends //! are wired here. The JS-facing `Image` wrapper (`Image.rs`) — constructor, -//! chainable mutators, `ConcurrentPromiseTask` plumbing — is re-exported as -//! the public surface of this module. +//! chainable mutators, pool-job plumbing — is re-exported as the public +//! surface of this module. // ─── codec dispatch surface ────────────────────────────────────────────────── // @@ -59,6 +59,5 @@ pub mod thumbhash; #[path = "Image.rs"] pub mod image_body; pub use image_body::{ - AsyncImageTask, Deliver, Fit, Image, Input, Kind, Modulate, Pipeline, PipelineTask, Resize, - Source, TaskResult, + Deliver, Fit, Image, Input, Kind, Modulate, Pipeline, PipelineTask, Resize, Source, TaskResult, }; diff --git a/src/runtime/ipc.rs b/src/runtime/ipc.rs index fbab0e9ea043..95f9d1e6c0bb 100644 --- a/src/runtime/ipc.rs +++ b/src/runtime/ipc.rs @@ -923,7 +923,7 @@ impl SendQueueOwner { .this_value .get() .try_get() - .unwrap_or(JSValue::ZERO), + .unwrap_or_default(), SendQueueOwner::Instance(_) => JSValue::ZERO, } } @@ -1133,8 +1133,8 @@ impl SendQueue { unsafe { ::deref(this) }; } - /// `__bun_release_task_at_shutdown` hook: a scheduled deferred task that - /// will never run still owns a ref; drop it (skipping the JS callbacks). + /// `Taskable::release_unrun`: a scheduled deferred task that will never + /// run still owns a ref; drop it (skipping the JS callbacks). /// /// # Safety /// `this` is the queued root pointer, live via the ref taken at schedule. @@ -1143,6 +1143,17 @@ impl SendQueue { unsafe { ::deref(this) }; } + /// `uv::open_handles` closes the channel's pipe through here at a thread + /// teardown: close now (pending writes finish ECANCELED) and let the owner + /// observe the disconnect, rather than waiting for writes as a user close does. + #[cfg(windows)] + unsafe fn stop_for_vm_teardown(this: *mut c_void) { + // SAFETY: recorded at configure time by this live SendQueue; the pipe + // leaves the list when `windows_close` issues its uv_close. + let this = unsafe { &*this.cast::() }; + this.windows_close(true); + } + #[cfg(windows)] fn windows_close(&self, notify: bool) { log!("SendQueue#_windowsClose"); @@ -1695,6 +1706,11 @@ impl SendQueue { // SAFETY: caller contract — `this` is a live SendQueue. let self_ = unsafe { &*this }; self_.socket.set(SocketUnion::Open(ipc_pipe)); + uv::open_handles::set_owner( + ipc_pipe.cast(), + this.cast(), + Some(Self::stop_for_vm_teardown), + ); self_.windows.with_mut(|w| w.is_server = true); // SAFETY: pipe is the live uv handle just stored in the socket cell. unsafe { (*ipc_pipe).data = this.cast() }; @@ -1748,6 +1764,11 @@ impl SendQueue { // SAFETY: caller contract — `this` is a live SendQueue. let self_ = unsafe { &*this }; self_.socket.set(SocketUnion::Open(ipc_pipe)); + uv::open_handles::set_owner( + ipc_pipe.cast(), + this.cast(), + Some(Self::stop_for_vm_teardown), + ); self_.windows.with_mut(|w| w.is_server = false); // SAFETY: ipc_pipe is the live uv handle just stored in the socket cell. @@ -1799,6 +1820,10 @@ impl uv::StreamReader for SendQueue { impl bun_event_loop::Taskable for SendQueue { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::SendQueueDeferred; + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — the SendQueue root queued with a held ref. + unsafe { SendQueue::release_deferred_unrun(this) } + } } impl Drop for SendQueue { diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 538ccf7e3715..60b81f6a0412 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -32,7 +32,8 @@ use bun_jsc::module_loader::{ }; use bun_jsc::resolved_source::OwnedResolvedSource; use bun_jsc::virtual_machine::{ - InitOptions, ResolveMode, RuntimeHooks, RuntimeState as OpaqueRuntimeState, VirtualMachine, + InitOptions, ResolveMode, RuntimeHooks, RuntimeState as OpaqueRuntimeState, SweepResult, + VirtualMachine, }; use bun_jsc::{ AnyPromise, ErrorCode, ErrorableResolvedSource, ErrorableString, JSGlobalObject, @@ -98,17 +99,44 @@ pub(crate) struct RuntimeState { /// has not been proven safe; keep the prior behavior of leaking any /// still-occupied slot while still freeing the pool allocation itself. pub(crate) body_value_pool: Box>, - pub(crate) isolation_handles: IsolationHandles, + pub(crate) active_handles: ActiveHandles, + /// The resolver's PackageManager wake-handler context (module queue + VM + /// handle); the resolver holds a raw pointer to it. Freed with the state. + pub(crate) wake_ctx: Option>, } #[derive(Clone, Copy, PartialEq, Eq, Hash)] -pub enum IsolationHandle { +/// A native handle behind a JS object that must be stopped while the VM is +/// alive rather than by a GC finalizer during `~VM` (Node's `HandleWrap` list): +/// registered while open, removed by its own close, and closed by +/// [`stop_active_handles_for_vm_teardown`] in every teardown's stop phase and at the +/// `bun test --isolate` global swap. +pub(crate) enum ActiveHandle { FsWatcher(ptr::NonNull), StatWatcher(ptr::NonNull), Server(crate::server::AnyServer), + Listener(ptr::NonNull), + /// A `Bun.udpSocket` / node:dgram socket: not in any uSockets group; on + /// Windows its armed receive is a request only closing the handle ends. + UdpSocket(ptr::NonNull), + /// TLS over a JS duplex (`tls.connect({ socket })`, `new TLSSocket(duplex)`): + /// not in any uSockets group, so closed through its owner. + DuplexUpgrade(ptr::NonNull), + /// A socket over a Windows named pipe: not in any uSockets group either. + #[cfg(windows)] + WindowsNamedPipe(ptr::NonNull), + /// A `fetch()` out on the HTTP thread; stopping it aborts the transport. + Fetch(ptr::NonNull), + /// An S3 request / streaming download out on the HTTP thread; same. + S3Request(ptr::NonNull), + S3Download(ptr::NonNull), + /// A `Bun.build` running on the bundle thread with this VM's plugins/env. + Bundle(ptr::NonNull), + /// A `dns.Resolver` (or the VM-global one) with a live c-ares channel. + DnsResolver(ptr::NonNull), } -pub(crate) type IsolationHandles = bun_collections::ArrayHashMap; +pub(crate) type ActiveHandles = bun_collections::ArrayHashMap; thread_local! { /// One `RuntimeState` per JS thread (`VirtualMachine` is per-thread). @@ -169,13 +197,30 @@ pub(crate) fn timer_all_mut() -> &'static mut timer::All { } #[inline] -pub(crate) fn isolation_handles() -> Option<&'static mut IsolationHandles> { +pub(crate) fn active_handles() -> Option<&'static mut ActiveHandles> { let state = runtime_state(); if state.is_null() { return None; } // SAFETY: live boxed per-thread `RuntimeState`. - Some(unsafe { &mut (*state).isolation_handles }) + Some(unsafe { &mut (*state).active_handles }) +} + +impl ActiveHandle { + /// This owner now holds something the stop phase must stop (JS thread). + pub(crate) fn register(self) { + if let Some(handles) = active_handles() { + bun_core::handle_oom(handles.put(self, ())); + } + } + + /// The owner closed it itself (JS thread; a no-op off-thread, where the + /// registry is unreachable and the owner must already have unregistered). + pub(crate) fn unregister(&self) { + if let Some(handles) = active_handles() { + handles.swap_remove(self); + } + } } /// Per-VM lazy DNS resolver storage. Shared borrow only — c-ares callbacks @@ -353,7 +398,8 @@ unsafe fn init_runtime_state( ) } }, - isolation_handles: IsolationHandles::default(), + active_handles: ActiveHandles::default(), + wake_ctx: None, })); RUNTIME_STATE.with(|c| c.set(state)); @@ -424,8 +470,14 @@ unsafe fn init_runtime_state( // from CLI args to the resolver so symlinked node_modules // entries resolve via their link path (peer deps stay reachable). t.resolver.opts.preserve_symlinks = preserve_symlinks; + let wake_ctx: *mut bun_jsc::async_module::WakeContext = &raw mut **(*state) + .wake_ctx + .insert(Box::new(bun_jsc::async_module::WakeContext { + queue: &raw mut (*vm).modules, + loop_handle: (*vm).loop_handle(), + })); t.resolver.on_wake_package_manager = bun_resolver::install_types::WakeHandler { - context: core::ptr::NonNull::new(ptr::addr_of_mut!((*vm).modules).cast()), + context: core::ptr::NonNull::new(wake_ctx.cast()), handler: Some(bun_jsc::async_module::Queue::on_wake_handler), on_dependency_error: Some({ unsafe fn adapter( @@ -434,10 +486,14 @@ unsafe fn init_runtime_state( id: bun_resolver::install_types::DependencyID, err: &'static str, ) { - // SAFETY: `ctx` is the `WakeHandler::context` set just above to `&mut (*vm).modules` (a live `Queue`). + // SAFETY: `ctx` is the `WakeContext` set just above; its queue is `(*vm).modules`. unsafe { bun_jsc::async_module::Queue::on_dependency_error( - ctx, dep, id, err, + bun_jsc::async_module::Queue::queue_from_wake_context(ctx) + .cast(), + dep, + id, + err, ) } } @@ -828,7 +884,7 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS unsafe { (*(*vm).event_loop()).perform_gc() }; // SAFETY: per fn contract — short-lived `&mut *vm`; `promise` is a // live protected JSC heap cell. - unsafe { (*vm).wait_for_promise(AnyPromise::Internal(promise)) }; + let _ = unsafe { (*vm).wait_for_promise(AnyPromise::Internal(promise)) }; } } @@ -836,6 +892,12 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS if unsafe { &*promise }.status() == PromiseStatus::Rejected { return Ok(promise); } + // A stop was requested (worker terminate()/exit) while it loaded: the + // caller checks the same and shuts down; load nothing more. + // SAFETY: per fn contract. + if !unsafe { &*vm }.script_allowed() { + return Ok(core::ptr::null_mut()); + } // `_protected` drops here → unprotect. } @@ -908,8 +970,10 @@ unsafe fn auto_tick(vm: *mut VirtualMachine) { // the `has_pending_immediate` read below is correct. // SAFETY: `el` is the live per-thread event loop; `vm` per fn contract. unsafe { (*el).tick_immediate_tasks(vm) }; + // SAFETY: as above. + let has_yielded_tasks = unsafe { (*el).promote_yield_tasks() }; #[cfg(windows)] - if !unsafe { &*el }.immediate_tasks.is_empty() { + if has_yielded_tasks || !unsafe { &*el }.immediate_tasks.is_empty() { // SAFETY: `el` is the live per-thread event loop. unsafe { (*el).wakeup() }; } @@ -972,7 +1036,10 @@ unsafe fn auto_tick(vm: *mut VirtualMachine) { // `tickImmediateTasks` swaps `next_immediate_tasks` in, so this // reflects next-tick immediates (queued during the drain above). // SAFETY: `el` is the live per-thread event loop. - let has_pending_immediate = !unsafe { &*el }.immediate_tasks.is_empty(); + // SAFETY: `el` is the live per-thread event loop. + let has_pending_immediate = has_yielded_tasks + || !unsafe { &*el }.immediate_tasks.is_empty() + || unsafe { &*el }.has_pending_tasks(); // Fold the QUIC deadline into the poll timeout. // SAFETY: `loop_` is the live per-thread uws loop. let quic_next_tick_us = unsafe { @@ -1064,8 +1131,10 @@ unsafe fn auto_tick_active(vm: *mut VirtualMachine) { // SAFETY: `el` is the live per-thread event loop; `vm` per fn contract. unsafe { (*el).tick_immediate_tasks(vm) }; + // SAFETY: as above. + let has_yielded_tasks = unsafe { (*el).promote_yield_tasks() }; #[cfg(windows)] - if !unsafe { &*el }.immediate_tasks.is_empty() { + if has_yielded_tasks || !unsafe { &*el }.immediate_tasks.is_empty() { // SAFETY: `el` is the live per-thread event loop. unsafe { (*el).wakeup() }; } @@ -1105,7 +1174,10 @@ unsafe fn auto_tick_active(vm: *mut VirtualMachine) { { // SAFETY: `el` is the live per-thread event loop. - let has_pending_immediate = !unsafe { &*el }.immediate_tasks.is_empty(); + // SAFETY: `el` is the live per-thread event loop. + let has_pending_immediate = has_yielded_tasks + || !unsafe { &*el }.immediate_tasks.is_empty() + || unsafe { &*el }.has_pending_tasks(); // SAFETY: `loop_` is the live per-thread uws loop. let quic_next_tick_us = unsafe { let ild = &(*loop_).internal_loop_data; @@ -1470,12 +1542,14 @@ static __BUN_RUNTIME_HOOKS: RuntimeHooks = RuntimeHooks { load_standalone_sourcemap, apply_standalone_runtime_flags, parse_worker_exec_argv_allow_addons, - cron_clear_all_teardown, + stop_cron_for_vm_teardown, cron_clear_all_reload, - terminate_all_workers_and_wait, retroactively_report_discovered_tests, cancel_all_timers, - close_dns_for_terminate, + stop_dns_for_vm_teardown, + stop_active_handles_for_vm_teardown: stop_active_handles_for_vm_teardown_hook, + disarm_all_timers_for_vm_teardown, + close_timer_loop_handles_after_vm_destroyed, }; // ════════════════════════════════════════════════════════════════════════════ @@ -1553,13 +1627,13 @@ unsafe fn parse_worker_exec_argv_allow_addons( /// stops every in-process `Bun.cron()` job registered on /// this VM and releases the pending-promise ref so the struct frees (the event /// loop is dying; settle callbacks will never run). -fn cron_clear_all_teardown(vm: &mut VirtualMachine) { +fn stop_cron_for_vm_teardown(vm: &mut VirtualMachine) { use crate::api::cron::{ClearMode, CronJob}; CronJob::clear_all_for_vm::<{ ClearMode::Teardown }>(vm); } /// `jsc.API.cron.CronJob.clearAllForVM(vm, .reload)` — -/// same impl as [`cron_clear_all_teardown`] but skips +/// same impl as [`stop_cron_for_vm_teardown`] but skips /// the pending-promise force-release (the event loop survives a hot reload, so /// settle callbacks will still run). fn cron_clear_all_reload(vm: &mut VirtualMachine) { @@ -1567,17 +1641,6 @@ fn cron_clear_all_reload(vm: &mut VirtualMachine) { CronJob::clear_all_for_vm::<{ ClearMode::Reload }>(vm); } -/// `webcore.WebWorker.terminateAllAndWait(timeout_ms)` — -/// forwards to the in-crate `bun_jsc::web_worker` -/// implementation; routed through `RuntimeHooks` because `virtual_machine.rs` -/// sits below `web_worker.rs` in the module DAG and the wait re-enters -/// `auto_tick` (this crate) on the worker side. -/// -/// Main-thread only; called from `global_exit` after `is_shutting_down` is set. -fn terminate_all_workers_and_wait(timeout_ms: u64) { - bun_jsc::web_worker::terminate_all_and_wait(timeout_ms); -} - /// `RuntimeHooks::cancel_all_timers` — cancel every `TimeoutObject` / /// `ImmediateObject` still linked in the current thread's timer heap so the /// in-heap `+1` ref and the JS pin drop before the GC sweep / `~VM`. @@ -1609,34 +1672,93 @@ unsafe fn cancel_all_timers(vm: *mut VirtualMachine) { } } -/// `RuntimeHooks::close_dns_for_terminate` — destroy the per-VM global DNS +/// `RuntimeHooks::close_timer_loop_handles_after_vm_destroyed`: teardown-only companion of +/// `cancel_all_timers` (which the `--isolate` swap also uses on a live VM). +/// +/// # Safety +/// `runtime_state()` is installed; JS thread; the JSC VM is already destroyed. +unsafe fn close_timer_loop_handles_after_vm_destroyed(_vm: *mut VirtualMachine) { + #[cfg(windows)] + { + let state = runtime_state(); + debug_assert!(!state.is_null()); + // SAFETY: live boxed per-thread RuntimeState (fn contract). + unsafe { (*state).timer.close_loop_handles_for_vm_teardown() }; + } +} + +/// `RuntimeHooks::stop_active_handles_for_vm_teardown` — see [`stop_active_handles_for_vm_teardown`]. +/// +/// # Safety +/// `vm` is the live per-thread VM on the JS thread; the JSC heap is alive. +unsafe fn stop_active_handles_for_vm_teardown_hook(vm: *mut VirtualMachine) -> SweepResult { + // SAFETY: per the contract above. + stop_active_handles_for_vm_teardown(unsafe { &mut *vm }) +} + +/// `RuntimeHooks::disarm_all_timers_for_vm_teardown`. +unsafe fn disarm_all_timers_for_vm_teardown(_vm: *mut VirtualMachine) { + let all = timer_all(); + if all.is_null() { + return; + } + // SAFETY: live per-thread `All`; JS thread; teardown has forbidden script. + unsafe { crate::timer::All::disarm_all_for_vm_teardown(all) }; +} + +/// `RuntimeHooks::stop_dns_for_vm_teardown` — destroy the per-VM global DNS /// resolver's c-ares channel now so its `ARES_EDESTRUCTION` and socket-state /// callbacks run while the JSC VM, `RareData.file_polls`, and `runtime_state` /// are all still live. See `Resolver::close_channel_for_terminate`. -fn close_dns_for_terminate() { +fn stop_dns_for_vm_teardown() -> SweepResult { let state = runtime_state(); if state.is_null() { - return; + return SweepResult::Idle; } + let mut result = SweepResult::Idle; // SAFETY: `state` is the live per-thread `RuntimeState` box; shared borrow // of the `OnceCell` only (the resolver's own state is interior-mutable). if let Some(gd) = unsafe { &(*state).global_dns_data }.get() { - gd.resolver.close_channel_for_terminate(); + // SAFETY: the VM-global resolver, pinned by `GlobalData` (its own ref + // never drops here). + result = result.and(unsafe { + crate::dns_jsc::Resolver::close_channel_for_terminate(gd.resolver.as_ctx_ptr()) + }); + #[cfg(windows)] + gd.resolver.cancel_pending_uv_requests_for_teardown(); } #[cfg(target_os = "macos")] crate::dns_jsc::dns_sd::SharedConnection::close_for_terminate(); + result +} + +/// `--isolate` swap: a microtask still pending at end-of-file (queued by +/// `tick_immediate_tasks` or `handle_rejected_promises`) can register new +/// handles when it runs, so drain first so they land in the registry before it +/// empties, then stop. (VM teardown must *not* drain here — its +/// prepareForDestruction discards the pre-exit queues.) +pub(crate) fn stop_active_handles_for_test_isolation(vm: &mut VirtualMachine) { + let _ = vm.event_loop_mut().drain_microtasks(); + let _ = stop_active_handles(vm, StopReason::TestIsolation); +} + +pub(crate) fn stop_active_handles_for_vm_teardown(vm: &mut VirtualMachine) -> SweepResult { + stop_active_handles(vm, StopReason::VmTeardown) } -pub(crate) fn close_isolation_handles(vm: &mut VirtualMachine) { +#[derive(Clone, Copy, PartialEq, Eq)] +enum StopReason { + VmTeardown, + /// The VM keeps running (`bun test --isolate` global swap). + TestIsolation, +} + +fn stop_active_handles(vm: &mut VirtualMachine, reason: StopReason) -> SweepResult { let state = runtime_state(); if state.is_null() { - return; + return SweepResult::Idle; } - // A microtask still pending at end-of-file (e.g. queued by - // `tick_immediate_tasks` or `handle_rejected_promises`) can register new - // handles when it runs. Drain first so they land in the registry before - // it empties — matches the swap's own drain-before-teardown ordering. - let _ = vm.event_loop_mut().drain_microtasks(); + let mut result = SweepResult::Idle; // Fake-timer state lives in the per-thread `timer::All`, not the JS // global, so a file that leaves it active routes every later file's // `setTimeout` into the never-driven fake heap. Leave the heap itself @@ -1655,22 +1777,75 @@ pub(crate) fn close_isolation_handles(vm: &mut VirtualMachine) { unsafe { (*all).fake_timers.reset_for_isolation(global) }; } } + // Entries that stay registered across a test-isolation swap. + let mut kept: Vec = Vec::new(); loop { // SAFETY: live boxed per-thread `RuntimeState`; the borrow ends before // the close below re-enters JS. - let Some(kv) = (unsafe { &mut (*state).isolation_handles }).pop() else { + let Some(kv) = (unsafe { &mut (*state).active_handles }).pop() else { break; }; + result = SweepResult::Stopped; match kv.key { // SAFETY: live until it unregisters in `detach`. - IsolationHandle::FsWatcher(w) => unsafe { w.as_ref() }.close_for_isolation(), + ActiveHandle::FsWatcher(w) => unsafe { w.as_ref() }.close_for_isolation(), // Live until it unregisters in `close()` (JS thread, us) — a // registered entry implies `close()` has not run, and `deinit` // cannot fire before `close()` drops the wrapper's Strong ref. - IsolationHandle::StatWatcher(w) => bun_ptr::ParentRef::from(w).close(), - IsolationHandle::Server(mut s) => s.stop(true), + ActiveHandle::StatWatcher(w) => bun_ptr::ParentRef::from(w).close(), + ActiveHandle::Server(mut s) => s.stop(true), + ActiveHandle::Listener(l) => { + // SAFETY: live until it unregisters in `do_stop`/`finalize`. + crate::socket::Listener::stop_for_vm_teardown(unsafe { l.as_ref() }) + } + ActiveHandle::UdpSocket(u) => { + // SAFETY: live until it unregisters in `on_close`. + crate::socket::udp_socket::UDPSocket::stop_for_vm_teardown(unsafe { u.as_ref() }) + } + // SAFETY: live until it unregisters in `deinit`. + ActiveHandle::DuplexUpgrade(c) => unsafe { + crate::socket::DuplexUpgradeContext::stop_for_vm_teardown(c.as_ptr()) + }, + // SAFETY: live until it unregisters when its deinit task runs. + #[cfg(windows)] + ActiveHandle::WindowsNamedPipe(c) => unsafe { + crate::socket::WindowsNamedPipeContext::stop_for_vm_teardown(c.as_ptr()) + }, + // SAFETY: live until it unregisters in `deinit`. + ActiveHandle::Fetch(t) => unsafe { + crate::webcore::fetch::FetchTasklet::stop_for_vm_teardown(t.as_ptr()) + }, + // SAFETY: live until they unregister in `on_response`. + ActiveHandle::S3Request(t) => unsafe { + crate::webcore::s3::simple_request::S3HttpSimpleTask::stop_for_vm_teardown( + t.as_ptr(), + ) + }, + // SAFETY: as above. + ActiveHandle::S3Download(t) => unsafe { + crate::webcore::s3::download_stream::S3HttpDownloadStreamingTask::stop_for_vm_teardown(t.as_ptr()) + }, + // A live VM cannot cancel a build: hop tasks it already queued here + // would still be dispatched against the finished pass. The build + // runs on; its completion lands on the next file's global. + ActiveHandle::Bundle(_) if reason == StopReason::TestIsolation => kept.push(kv.key), + // SAFETY: live until it unregisters in `on_complete_anytask`. + ActiveHandle::Bundle(c) => unsafe { + crate::api::js_bundle_completion_task::JSBundleCompletionTask::stop_for_vm_teardown( + c.as_ptr(), + ) + }, + // Live until it unregisters in `destroy_channel`. + // SAFETY: registered ⇒ live; may free itself inside, not touched after. + ActiveHandle::DnsResolver(r) => unsafe { + let _ = crate::dns_jsc::Resolver::close_channel_for_terminate(r.as_ptr()); + }, } } + for handle in kept { + handle.register(); + } + result } /// `TestReporterAgent.retroactivelyReportDiscoveredTests(agent, next_test_id)`. diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index 12cd0b609cce..990c739f2ca0 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -43,12 +43,31 @@ impl JSValueNapiExt for JSValue { // `Taskable` impls for the napi heap tasks dispatched through the JS event loop. impl Taskable for napi_async_work { const TAG: TaskTag = task_tag::NapiAsyncWork; + /// Work the pool handed back during teardown: its `complete` callback is + /// how the addon learns the outcome and frees the work (Node calls it from + /// environment cleanup too); script it tries to run is refused at the boundary. + unsafe fn release_unrun(this: *mut Self) { + let vm = VirtualMachine::get().as_mut(); + let global = vm.global(); + // SAFETY: fn contract — the addon's live work object the pool posted. + unsafe { (*this).run_from_js(vm, global) }; + } } impl Taskable for ThreadSafeFunction { const TAG: TaskTag = task_tag::ThreadSafeFunction; + /// `this` is the TSFN itself, which the env's cleanup hook (`env_teardown`, + /// run with the exit handlers before the queue is released) already + /// neutralised or freed. Nothing to do, and `this` must not be dereferenced. + unsafe fn release_unrun(_: *mut Self) {} } impl Taskable for NapiFinalizerTask { const TAG: TaskTag = task_tag::NapiFinalizerTask; + /// A finalizer queued before the loop stopped: Node runs an addon's + /// finalizers during environment cleanup (script already forbidden), and + /// an addon counts on them (external buffers freed when a Worker exits). + unsafe fn release_unrun(this: *mut Self) { + NapiFinalizerTask::run_on_js_thread(this); + } } bun_output::declare_scope!(napi, visible); @@ -67,12 +86,15 @@ bun_opaque::opaque_ffi! { pub struct NapiEnv; } +#[allow(improper_ctypes)] // `vm_handle::Shared` is opaque to C++ (`BunVmHandleRef`) unsafe extern "C" { fn NapiEnv__globalObject(env: *mut NapiEnv) -> *mut JSGlobalObject; fn NapiEnv__getAndClearPendingException(env: *mut NapiEnv, out: *mut JSValue) -> bool; fn NapiEnv__hasPendingException(env: *mut NapiEnv) -> bool; fn NapiEnv__deref(env: *mut NapiEnv); fn NapiEnv__ref(env: *mut NapiEnv); + /// The reference to its VM's handle the env holds (`BunVmHandleRef`). + fn NapiEnv__vmHandle(env: *mut NapiEnv) -> *const bun_jsc::vm_handle::Shared; fn napi_set_last_error(env: napi_env, status: NapiStatus) -> napi_status; } @@ -82,6 +104,12 @@ impl NapiEnv { unsafe { &*NapiEnv__globalObject(self.as_mut_ptr()) } } + /// Any thread holding an env ref: the env's VM handle. + pub(crate) fn vm_handle(&self) -> bun_jsc::vm_handle::BorrowedRef { + // SAFETY: the env holds a reference for its whole lifetime. + unsafe { bun_jsc::VmHandle::borrow_ref(NapiEnv__vmHandle(self.as_mut_ptr())) } + } + /// Convert err to an extern napi_status, and store the error code in env so that it can be /// accessed by napi_get_last_error_info pub(crate) fn set_last_error(self_: Option<&Self>, err: NapiStatus) -> napi_status { @@ -1725,8 +1753,10 @@ pub(crate) struct napi_async_work { pub task: WorkPoolTask, pub(crate) concurrent_task: ConcurrentTask, // Note: BackRef — `enqueue_task` needs `&mut EventLoop`; reborrowed at use sites. - pub(crate) event_loop: bun_ptr::BackRef, - pub global: GlobalRef, // JSC_BORROW (lives for vm lifetime) + /// How the pool thread delivers completion / cancellation to the VM. + pub(crate) loop_handle: bun_jsc::LoopHandle, + /// JS thread only. + pub global: GlobalRef, pub(crate) env: NapiEnvRef, pub(crate) execute: napi_async_execute_callback, pub(crate) complete: Option, @@ -1757,9 +1787,7 @@ impl napi_async_work { // SAFETY: env outlives the async work; clone bumps the C++ refcount. env: unsafe { NapiEnvRef::clone_from_raw(env.as_mut_ptr()) }, execute, - // SAFETY: `event_loop()` is the live JS-thread loop (non-null, - // stable address) and outlives every napi_async_work. - event_loop: unsafe { bun_ptr::BackRef::from_raw(global.bun_vm().event_loop()) }, + loop_handle: global.bun_vm().loop_handle(), complete, data, status: AtomicU32::new(AsyncWorkStatus::Pending as u32), @@ -1783,6 +1811,10 @@ impl napi_async_work { } self.scheduled = true; self.poll_ref.ref_(bun_io::js_vm_ctx()); + // The work object belongs to the addon and `execute` receives this + // env: counted, so the VM waits for it (Node likewise settles its + // threadpool requests before an environment is freed). + self.loop_handle.embedded_work_scheduled(); WorkPool::schedule(&raw mut self.task); } @@ -1794,34 +1826,47 @@ impl napi_async_work { fn run(&mut self) { let self_ptr: *mut Self = self; - if let Err(state) = self.status.compare_exchange( - AsyncWorkStatus::Pending as u32, - AsyncWorkStatus::Started as u32, - Ordering::SeqCst, - Ordering::SeqCst, - ) { - if state == AsyncWorkStatus::Cancelled as u32 { - // `concurrent_task` is the live inline field of this heap work; - // the queue takes ownership of its `next` link. - self.event_loop - .enqueue_task_concurrent(core::ptr::NonNull::from( - self.concurrent_task - .from(self_ptr, AutoDeinit::ManualDeinit), - )); - return; - } + let handle = self.loop_handle.clone(); + // A VM that is already stopping cancels work it has not started, as + // Node's environment cleanup does (uv_cancel); otherwise `execute` runs + // with the VM held open. + let vm = handle.borrow_if_running(); + let started = vm.is_some() + && match self.status.compare_exchange( + AsyncWorkStatus::Pending as u32, + AsyncWorkStatus::Started as u32, + Ordering::SeqCst, + Ordering::SeqCst, + ) { + Ok(_) => true, + Err(state) => state != AsyncWorkStatus::Cancelled as u32, + }; + if started { + (self.execute)(self.env.get(), self.data); + self.status + .store(AsyncWorkStatus::Completed as u32, Ordering::SeqCst); + } else { + let _ = self.cancel(); } - (self.execute)(self.env.get(), self.data); - self.status - .store(AsyncWorkStatus::Completed as u32, Ordering::SeqCst); - - // `concurrent_task` is the live inline field of this heap work; the - // queue takes ownership of its `next` link. - self.event_loop - .enqueue_task_concurrent(core::ptr::NonNull::from( - self.concurrent_task - .from(self_ptr, AutoDeinit::ManualDeinit), - )); + drop(vm); + self.post_to_js_thread(self_ptr); + // `self` may already be freed by the JS thread; the handle is ours. + handle.embedded_work_finished(); + } + + /// Pool thread → JS thread: run `complete` there. `concurrent_task` is the + /// live inline field of this heap work; the queue takes ownership of its + /// `next` link. Counted work, so the VM has not closed its handle; a VM + /// tearing down runs `complete` from its queue release (status cancelled + /// if `execute` never ran), as Node does at environment cleanup. + fn post_to_js_thread(&mut self, self_ptr: *mut Self) { + let ct = core::ptr::NonNull::from( + self.concurrent_task + .from(self_ptr, AutoDeinit::ManualDeinit), + ); + let bun_jsc::vm_handle::Posted::Queued = self.loop_handle.post_task(ct) else { + unreachable!("VM handle closed with napi async work outstanding"); + }; } pub(crate) fn cancel(&mut self) -> bool { @@ -2399,7 +2444,11 @@ pub(crate) struct ThreadSafeFunction { // EventLoop`; reborrowed at use sites (single JS thread). `None` once the // owning env is torn down: the loop lives inside a VirtualMachine that a // worker's shutdown frees, while addon threads outlive it. + /// JS-thread uses; `None` once the env has been torn down. pub(crate) event_loop: Option>, + /// How addon threads (`napi_call_threadsafe_function`) schedule a + /// dispatch on the VM. + pub(crate) loop_handle: bun_jsc::LoopHandle, pub(crate) tracker: Debugger::AsyncTaskTracker, /// Dropped on the JS thread by `env_teardown`; `None` afterwards. @@ -2778,8 +2827,7 @@ impl ThreadSafeFunction { } /// Caller must hold `lock`. Reached from addon threads (`enqueue`, - /// `release_locked`), so it may only take a shared `&EventLoop`: the JS - /// thread can be inside `tick()` with its own `&mut` at the same time. + /// `release_locked`); the VM is reached only through its handle. fn schedule_dispatch(&mut self) { let prev = self .dispatch_state @@ -2787,11 +2835,20 @@ impl ThreadSafeFunction { match prev { x if x == DispatchState::Idle as u8 => { let self_ptr: *mut Self = self; - let Some(event_loop) = self.event_loop.as_ref() else { + if self.event_loop.is_none() { // env torn down: the loop is gone, nothing to schedule onto. return; - }; - event_loop.enqueue_task_concurrent(ConcurrentTask::create_from(self_ptr)); + } + let ct = ConcurrentTask::create_from(self_ptr); + if let bun_jsc::vm_handle::Posted::Refused(ct) = self.loop_handle.post_task(ct) { + // VM torn down before the env cleanup hook ran here: no + // dispatch will happen; the queued calls are released by the + // teardown path. Free the task and fall back to Idle. + // SAFETY: refused ⇒ we own the task box. + unsafe { drop(bun_core::heap::take(ct.as_ptr())) }; + self.dispatch_state + .store(DispatchState::Idle as u8, Ordering::SeqCst); + } } x if x == DispatchState::Running as u8 => { // it will check if it has more work to do @@ -2912,20 +2969,20 @@ impl ThreadSafeFunction { drop(self.env.take()); self.env_teardown_done.store(true, Ordering::SeqCst); // Cleanup hooks are the loop's last tick: a task still queued for this - // TSFN will never run (no tag arm in `__bun_release_task_at_shutdown` - // dereferences it either). With no thread_count reference left, nobody - // else can reach this, so free it here. + // TSFN will never run (and its `release_unrun` does not dereference it). + // With no thread_count reference left, nobody else can reach this, so + // free it here. self.thread_count.load(Ordering::SeqCst) <= 0 } + /// `napi_ref_threadsafe_function` — JS thread only (as in Node). pub(crate) fn ref_(&mut self) { - self.poll_ref - .ref_concurrently_from_event_loop(bun_io::js_vm_ctx()); + self.poll_ref.ref_(bun_io::js_vm_ctx()); } + /// `napi_unref_threadsafe_function` — JS thread only (as in Node). pub(crate) fn unref(&mut self) { - self.poll_ref - .unref_concurrently_from_event_loop(bun_io::js_vm_ctx()); + self.poll_ref.unref(bun_io::js_vm_ctx()); } pub(crate) fn acquire(&mut self) -> napi_status { @@ -3073,6 +3130,7 @@ extern "C" fn napi_create_threadsafe_function( // SAFETY: the loop is live now; `NapiEnv::cleanup()` clears this field // (via `env_teardown`) before the VirtualMachine holding it is freed. event_loop: Some(unsafe { bun_ptr::BackRef::from_raw_mut(vm.event_loop()) }), + loop_handle: vm.loop_handle(), // SAFETY: env is a live C++-owned napi_env. env: Some(unsafe { NapiEnvRef::clone_from_raw(env.as_mut_ptr()) }), callback, @@ -5102,10 +5160,25 @@ impl NapiFinalizerTask { let is_main_thread = VirtualMachine::get_or_null().is_some(); if !is_main_thread { - // TODO(@heimskr): do we need to handle the case where the vm is shutting down? + // Off the JS thread (e.g. an external buffer finalized from a GC + // helper thread): post through the env's VM handle. If the VM is + // already torn down the finalizer can never run; free the task but + // not the env ref — the env's count is not atomic and the env goes + // with its VM — as the cleanup-hooks-already-ran case below does. + // SAFETY: env is valid (held by NapiEnvRef). + let handle = unsafe { &*self.finalizer.env.get() }.vm_handle().clone(); let this = bun_core::heap::into_raw(self); - vm.event_loop_ref() - .enqueue_task_concurrent(ConcurrentTask::create(Task::init(this))); + let ct = ConcurrentTask::create(Task::init(this)); + if let bun_jsc::vm_handle::Posted::Refused(ct) = + handle.post(bun_jsc::LoopKind::Regular, ct) + { + // SAFETY: refused ⇒ we own both boxes. + unsafe { + drop(bun_core::heap::take(ct.as_ptr())); + let task = bun_core::heap::take(this); + let _ = core::mem::ManuallyDrop::new(task.finalizer.env); + } + } return; } diff --git a/src/runtime/node/memory_pressure.rs b/src/runtime/node/memory_pressure.rs index d0962eafdc3f..b45c180481d5 100644 --- a/src/runtime/node/memory_pressure.rs +++ b/src/runtime/node/memory_pressure.rs @@ -56,8 +56,17 @@ pub(crate) fn emit(global: &JSGlobalObject, lvl: i32) { unsafe { Process__emitMemoryPressureEvent(core::ptr::from_ref(global).cast_mut(), lvl) }; } +/// The queued form of a pressure notification: `Task::ptr` packs the level, +/// there is no allocation. +pub(crate) struct MemoryPressureTask; +impl bun_event_loop::Taskable for MemoryPressureTask { + const TAG: bun_event_loop::TaskTag = task_tag::MemoryPressureTask; + /// Nothing is owned (`this` is the packed level). + unsafe fn release_unrun(_: *mut Self) {} +} + fn pressure_task(lvl: i32) -> Task { - Task::new(task_tag::MemoryPressureTask, lvl as usize as *mut ()) + Task::init(lvl as usize as *mut MemoryPressureTask) } #[cfg(not(windows))] @@ -303,7 +312,7 @@ mod windows { vm.rare_data().memory_pressure_watcher_slot() } - fn thread_main(vm_addr: usize, notify: usize, shutdown: usize) { + fn thread_main(vm: bun_jsc::VmHandle, notify: usize, shutdown: usize) { bun_core::output::Source::configure_named_thread(bun_core::zstr!("MemoryPressure")); let handles: [HANDLE; 2] = [shutdown as HANDLE, notify as HANDLE]; loop { @@ -313,10 +322,14 @@ mod windows { break; } let task = ConcurrentTask::create(super::pressure_task(super::level::CRITICAL)); - // SAFETY: main-thread VM captured at install; process-lifetime. - unsafe { &*(vm_addr as *const VirtualMachine) } - .event_loop_shared() - .enqueue_task_concurrent(task); + if let bun_jsc::vm_handle::Posted::Refused(task) = + vm.post(bun_jsc::LoopKind::Regular, task) + { + // VM torn down (uninstall joins us right after): drop the notification. + // SAFETY: refused ⇒ we own the task box. + unsafe { drop(bun_core::heap::take(task.as_ptr())) }; + break; + } // SAFETY: `shutdown` is valid for the thread's lifetime. if unsafe { WaitForSingleObject(handles[0], HOLDOFF_MS) } == WAIT_OBJECT_0 { break; @@ -343,15 +356,15 @@ mod windows { } let shutdown = OwnedHandle(shutdown); - let (vm_addr, n, s) = ( - core::ptr::from_ref(global.bun_vm()) as usize, + let (vm, n, s) = ( + global.bun_vm().handle(), notify.0 as usize, shutdown.0 as usize, ); let Ok(thread) = std::thread::Builder::new() .name("MemoryPressure".into()) .stack_size(64 * 1024) - .spawn(move || thread_main(vm_addr, n, s)) + .spawn(move || thread_main(vm, n, s)) else { return; }; diff --git a/src/runtime/node/node_crypto_binding.rs b/src/runtime/node/node_crypto_binding.rs index d298afc4247c..8d995403a155 100644 --- a/src/runtime/node/node_crypto_binding.rs +++ b/src/runtime/node/node_crypto_binding.rs @@ -5,9 +5,10 @@ use core::ffi::{c_char, c_void}; use bun_boringssl as boringssl; use bun_collections::CaseInsensitiveAsciiStringArrayHashMap; +use bun_jsc::vm_handle::Borrow; use bun_jsc::{ - self as jsc, AnyTaskJob, AnyTaskJobCtx, ArrayBuffer, CallFrame, JSGlobalObject, JSValue, - JsResult, StrongOptional, + self as jsc, ArrayBuffer, CallFrame, JSGlobalObject, JSValue, Job, JobContext, JsPtr, JsResult, + JsThread, Protected, Strong, }; use crate::node::StringOrBuffer; @@ -94,7 +95,7 @@ macro_rules! extern_crypto_job { // the ctx and then invokes. unsafe extern "C" { #[link_name = concat!("Bun__", $name_str, "Ctx__runTask")] - safe fn ctx_run_task(ctx: &Ctx, global: *mut JSGlobalObject); + safe fn ctx_run_task(ctx: &Ctx, global: &JSGlobalObject); #[link_name = concat!("Bun__", $name_str, "Ctx__runFromJS")] safe fn ctx_run_from_js( ctx: &Ctx, @@ -105,39 +106,51 @@ macro_rules! extern_crypto_job { safe fn ctx_deinit(ctx: &Ctx); } - pub(crate) struct ExternCtx { - // Null once `then` has freed it. - ctx: *mut Ctx, - callback: StrongOptional, + /// The C++ context this job owns: plain data, freed wherever the job ends. + pub(crate) struct OwnedCtx(*mut Ctx); + // SAFETY: an owned C++ heap object with no thread affinity. + unsafe impl Send for OwnedCtx {} + impl Drop for OwnedCtx { + fn drop(&mut self) { + ctx_deinit(Ctx::opaque_ref(self.0)); + } } - impl ExternCtx { - fn deinit_ctx(&mut self) { - ctx_deinit(Ctx::opaque_ref(self.ctx)); - self.ctx = core::ptr::null_mut(); - } + pub(crate) struct ExternJob { + ctx: OwnedCtx, + global: JsPtr, } - impl AnyTaskJobCtx for ExternCtx { - fn run(&mut self, global: *mut JSGlobalObject) { - ctx_run_task(Ctx::opaque_ref(self.ctx), global); + impl JobContext for ExternJob { + type OffThread = Self; + type Js = Strong; + + fn run( + this: &mut Self, + vm: &Borrow, + done: bun_jsc::Completion, + ) -> Option> { + // SAFETY: the creating global, alive under the borrow; C++ + // only threads it through to error reporting state. + ctx_run_task(Ctx::opaque_ref(this.ctx.0), unsafe { + this.global.under_borrow(vm) + }); + Some(done) } - fn then(&mut self, global: &JSGlobalObject) -> JsResult<()> { - let Some(callback) = self.callback.try_swap() else { - return Ok(()); - }; + + fn then(this: Self, callback: Strong, cx: &JsThread<'_>) -> JsResult<()> { + let global = cx.global(); let mut args = JsCallbackArgs::EMPTY; let produced = jsc::from_js_host_call_generic(global, || { - ctx_run_from_js(Ctx::opaque_ref(self.ctx), global, &mut args); + ctx_run_from_js(Ctx::opaque_ref(this.ctx.0), global, &mut args); }); - // Free the ctx before user JS (the callback, or an - // uncaughtException handler) — user code may never return - // (`process.exit()`). - self.deinit_ctx(); + // `runFromJS` never sees the callback, so it cannot run user + // JS; free the ctx first, then invoke. + drop(this); match produced { Ok(()) => { global.bun_vm().event_loop_mut().run_callback( - callback, + callback.get(), global, JSValue::UNDEFINED, args.as_slice(), @@ -149,57 +162,23 @@ macro_rules! extern_crypto_job { } } - impl Drop for ExternCtx { - fn drop(&mut self) { - // Non-null when the job dies without completing (shutdown - // early-out, `init` failure, missing callback). - if !self.ctx.is_null() { - self.deinit_ctx(); - } - self.callback.deinit(); - } - } - - pub(crate) type Job = AnyTaskJob; - - // Exported C symbols. - #[unsafe(export_name = concat!("Bun__", $name_str, "__create"))] - pub(crate) extern "C" fn __create( - global: &JSGlobalObject, - ctx: *mut Ctx, - callback: JSValue, - ) -> *mut Job { - Job::create( - global, - ExternCtx { - ctx, - callback: StrongOptional::create(callback, global), - }, - ) - .expect("ExternCtx::init is infallible") - } - - #[unsafe(export_name = concat!("Bun__", $name_str, "__schedule"))] - pub(crate) extern "C" fn __schedule(this: &mut Job) { - // SAFETY: `this` is a live pointer returned by `__create`. - unsafe { Job::schedule(this) }; - } - #[unsafe(export_name = concat!("Bun__", $name_str, "__createAndSchedule"))] pub(crate) extern "C" fn __create_and_schedule( global: &JSGlobalObject, ctx: *mut Ctx, callback: JSValue, ) { + let cx = global.js_thread(); let callback = callback.with_async_context_if_needed(global); - Job::create_and_schedule( - global, - ExternCtx { - ctx, - callback: StrongOptional::create(callback, global), + Job::::schedule( + &cx, + ExternJob { + ctx: OwnedCtx(ctx), + // SAFETY: the creating global outlives every borrow of its VM. + global: unsafe { JsPtr::new(core::ptr::NonNull::from(global)) }, }, - ) - .expect("ExternCtx::init is infallible"); + Strong::create(callback, global), + ); } } }; @@ -222,84 +201,26 @@ extern_crypto_job!(SignJob, "SignJob"); // CryptoJob // ─────────────────────────────────────────────────────────────────────────── -/// Trait expressing the interface `CryptoJob` expects of `Ctx`. -trait CryptoJobCtx: Sized { - fn init(&mut self, global: &JSGlobalObject) -> JsResult<()>; - /// The impl reads its own `result` field directly. - fn run_task(&mut self); - fn run_from_js(&mut self, global: &JSGlobalObject, callback: JSValue); - fn deinit(&mut self); -} - -/// Adapter binding a [`CryptoJobCtx`] + JS callback into an [`AnyTaskJobCtx`]. -/// `Drop` runs `inner.deinit()` then releases the callback handle. -struct CallbackCtx { - callback: StrongOptional, - inner: C, -} - -impl AnyTaskJobCtx for CallbackCtx { - #[inline] - fn init(&mut self, global: &JSGlobalObject) -> JsResult<()> { - self.inner.init(global) - } - #[inline] - fn run(&mut self, _global: *mut JSGlobalObject) { - self.inner.run_task(); - } - fn then(&mut self, global: &JSGlobalObject) -> JsResult<()> { - let Some(callback) = self.callback.try_swap() else { - return Ok(()); - }; - self.inner.run_from_js(global, callback); - Ok(()) - } -} - -impl Drop for CallbackCtx { - fn drop(&mut self) { - self.inner.deinit(); - self.callback.deinit(); - } -} - -/// Kept as a free fn since `CryptoJob` is -/// a type alias for the foreign `AnyTaskJob<_>`. -fn crypto_job_init_and_schedule( - global: &JSGlobalObject, - callback: JSValue, - ctx: C, -) -> JsResult<()> { - AnyTaskJob::create_and_schedule( - global, - CallbackCtx { - callback: StrongOptional::create(callback.with_async_context_if_needed(global), global), - inner: ctx, - }, - ) -} - -// ─────────────────────────────────────────────────────────────────────────── -// random -// ─────────────────────────────────────────────────────────────────────────── pub mod random { use super::*; // No `Clone`: `value` is JSC-protected in `init`/unprotected in `deinit`, and // `bytes` borrows into that ArrayBuffer. Cloning would alias the protect/unprotect // pair and the borrowed buffer. `CryptoJob::init` moves the ctx by value. - struct JobCtx { - pub value: JSValue, - pub(crate) bytes: *mut u8, - pub offset: u32, - pub(crate) length: usize, - // Worker-owned destination for user-supplied buffers (`randomFill`). - // The user can detach (`transfer()`) or shrink (`resize()`) the backing - // store between scheduling and the WorkPool write, so the worker fills - // this scratch and `run_from_js` re-validates + copies on the JS thread. - // `randomBytes` allocates its own buffer (unreachable from JS until the - // callback fires) and leaves this `None`. - pub(crate) scratch: Option>, + /// `crypto.randomFill` / `randomBytes` off the JS thread: fills either + /// the target ArrayBuffer's bytes directly (under the VM borrow that keeps + /// them alive) or a scratch buffer copied in on completion. + struct RandomFillJob { + bytes: Option>, + offset: u32, + length: usize, + scratch: Option>, + } + + #[derive(bun_jsc::JsAffine)] + struct RandomFillJs { + callback: Strong, + value: Protected, } const MAX_POSSIBLE_LENGTH: usize = { @@ -309,57 +230,68 @@ pub mod random { }; const MAX_RANGE: i64 = 0xffff_ffff_ffff; - impl CryptoJobCtx for JobCtx { - fn init(&mut self, _: &JSGlobalObject) -> JsResult<()> { - self.value.protect(); - Ok(()) - } + impl JobContext for RandomFillJob { + type OffThread = Self; + type Js = RandomFillJs; - fn run_task(&mut self) { - if let Some(scratch) = &mut self.scratch { + fn run( + this: &mut Self, + vm: &Borrow, + done: bun_jsc::Completion, + ) -> Option> { + if let Some(scratch) = &mut this.scratch { boringssl::rand_bytes(scratch); - return; + return Some(done); } - // SAFETY: `bytes` points into an ArrayBuffer kept alive by `self.value` - // (protected in `init`); offset+length were range-checked by callers. - // This branch is only used for internally-allocated buffers that JS - // cannot reach (and therefore cannot detach/resize) until `run_from_js`. + let bytes = this.bytes.expect("bytes or scratch"); + // SAFETY: `bytes` points into the ArrayBuffer `value` keeps alive; the + // borrow keeps the VM (and so that buffer) alive; offset/length were + // range-checked against it on the JS thread. let slice = unsafe { - core::slice::from_raw_parts_mut(self.bytes.add(self.offset as usize), self.length) + core::slice::from_raw_parts_mut( + core::ptr::from_mut(bytes.under_borrow(vm)).add(this.offset as usize), + this.length, + ) }; boringssl::rand_bytes(slice); + Some(done) } - fn run_from_js(&mut self, global: &JSGlobalObject, callback: JSValue) { - if let Some(scratch) = self.scratch.take() { - // Re-fetch the buffer on the JS thread and re-validate bounds: - // the user may have detached or resized it while the WorkPool - // task ran. On mismatch, drop the random bytes rather than - // write through a stale pointer. - if let Some(mut buf) = self.value.as_array_buffer(global) { - let off = self.offset as usize; + fn then(mut this: Self, js: RandomFillJs, cx: &JsThread<'_>) -> JsResult<()> { + let global = cx.global(); + if let Some(scratch) = this.scratch.take() { + if let Some(mut buf) = js.value.value().as_array_buffer(global) { + let off = this.offset as usize; let dst = buf.slice_mut(); match off.checked_add(scratch.len()) { Some(end) if end <= dst.len() => { dst[off..end].copy_from_slice(&scratch); } + // Buffer was detached/shrunk while the job ran. _ => {} } } } - // `bun_vm()` is the audited safe `&'static VirtualMachine` accessor; - // `event_loop_mut()` is the audited safe `&mut EventLoop` accessor. global.bun_vm().event_loop_mut().run_callback( - callback, + js.callback.get(), global, JSValue::UNDEFINED, - &[JSValue::NULL, self.value], + &[JSValue::NULL, js.value.value()], ); + Ok(()) } + } - fn deinit(&mut self) { - self.value.unprotect(); - } + fn schedule(global: &JSGlobalObject, callback: JSValue, job: RandomFillJob, value: JSValue) { + let cx = global.js_thread(); + Job::::schedule( + &cx, + job, + RandomFillJs { + callback: Strong::create(callback.with_async_context_if_needed(global), global), + value: Protected::new(value), + }, + ); } mod _hostfns { @@ -670,14 +602,21 @@ pub mod random { return Ok(result); } - let ctx = JobCtx { - value: result, - bytes: bytes.as_mut_ptr(), - offset: 0, - length: size as usize, - scratch: None, - }; - crypto_job_init_and_schedule(global, callback, ctx)?; + schedule( + global, + callback, + RandomFillJob { + // SAFETY: `bytes` is `result`'s backing store, kept alive by the job's + // Js side; a slice's data pointer is non-null even when empty. + bytes: Some(unsafe { + JsPtr::new(core::ptr::NonNull::new_unchecked(bytes.as_mut_ptr())) + }), + offset: 0, + length: size as usize, + scratch: None, + }, + result, + ); Ok(JSValue::UNDEFINED) } @@ -784,14 +723,17 @@ pub mod random { } scratch.resize(size, 0); - let ctx = JobCtx { - value: buf_value, - bytes: core::ptr::null_mut(), - offset, - length: size, - scratch: Some(scratch), - }; - crypto_job_init_and_schedule(global, callback, ctx)?; + schedule( + global, + callback, + RandomFillJob { + bytes: None, + offset, + length: size, + scratch: Some(scratch), + }, + buf_value, + ); Ok(JSValue::UNDEFINED) } @@ -806,11 +748,7 @@ pub mod random { pub(crate) struct Scrypt { // Plain `StringOrBuffer` — NOT `ThreadSafe<_>`. The struct serves both // `scryptSync` (no protect taken) and async `scrypt` (protect taken in - // `from_js_maybe_async(.., true)`); wrapping in `ThreadSafe` here would make - // the sync path's drop call `JSValue::unprotect()` on a buffer it never - // protected, stealing a refcount from any independent protector. The async - // path releases its protect via `Unprotect for Scrypt` in - // `CryptoJobCtx::deinit` instead. + // `from_js_maybe_async(.., true)`, adopted into a `ThreadSafe` by the job). password: StringOrBuffer, salt: StringOrBuffer, n: u32, @@ -818,13 +756,6 @@ pub(crate) struct Scrypt { p: u32, maxmem: u64, keylen: u32, - - // used in async mode - buf: StrongOptional, // Strong.Optional, default .empty - // Invariant: `result` borrows the ArrayBuffer backing kept alive by `buf`; - // `buf` must stay set for as long as `result` is dereferenced. - result: *mut [u8], - err: Option, } mod _impl { @@ -1016,9 +947,6 @@ mod _impl { p: p.unwrap(), maxmem: u64::try_from(maxmem.unwrap()).expect("int cast"), keylen: u32::try_from(keylen).expect("int cast"), - buf: StrongOptional::empty(), - result: std::ptr::from_mut::<[u8]>(&mut []), - err: None, }; // Re-arm the error guard now that ownership moved into `ctx` — it // covers the `validateFunction`/`checkScryptParams` calls below. @@ -1075,18 +1003,18 @@ mod _impl { Ok(()) } - fn run_task_impl(&mut self, key: &mut [u8]) { + /// `Some(err)` on failure (`0` when there is no BoringSSL error code). + fn run_task_impl(&self, key: &mut [u8]) -> Option { let password = self.password.slice(); let salt = self.salt.slice(); if key.is_empty() { // result will be an empty buffer - return; + return None; } if password.len() > i32::MAX as usize || salt.len() > i32::MAX as usize { - self.err = Some(0); - return; + return Some(0); } // SAFETY: password/salt/key are valid slices for the given lengths. @@ -1106,21 +1034,15 @@ mod _impl { }; if res == 0 { - self.err = Some(boringssl::c::ERR_peek_last_error()); - return; + return Some(boringssl::c::ERR_peek_last_error()); } - } - - fn deinit_sync(&mut self) { - // `salt`/`password` are `StringOrBuffer` — released by `Drop` when - // `self` goes out of scope (the `scrypt_sync` scopeguard's `c`). - self.buf.deinit(); + None } } impl bun_jsc::Unprotect for Scrypt { /// Release the `protect()` taken by `from_js_maybe_async(.., true)` on the - /// async path. The sync path never calls this (see `deinit_sync`). + /// async path (via the job's `ThreadSafe`). The sync path never calls this. #[inline] fn unprotect(&mut self) { bun_jsc::Unprotect::unprotect(&mut self.password); @@ -1128,73 +1050,74 @@ mod _impl { } } - impl CryptoJobCtx for Scrypt { - fn init(&mut self, global: &JSGlobalObject) -> JsResult<()> { - if self.keylen as usize > jsc::virtual_machine::synthetic_allocation_limit() { - return Err(global.throw_out_of_memory()); - } - let (buf, bytes) = ArrayBuffer::alloc::<{ JSType::ArrayBuffer }>(global, self.keylen)?; + /// `crypto.scrypt` off the JS thread: derives straight into the result + /// ArrayBuffer's bytes under the VM borrow that keeps them alive. + pub(crate) struct ScryptJob { + params: bun_jsc::ThreadSafe, + result: JsPtr<[u8]>, + err: Option, + } - // to be filled in later - self.result = std::ptr::from_mut::<[u8]>(bytes); - self.buf = StrongOptional::create(buf, global); - Ok(()) - } + #[derive(bun_jsc::JsAffine)] + pub(crate) struct ScryptJs { + callback: Strong, + buf: Strong, + } - fn run_task(&mut self) { - // SAFETY: `result` points into the ArrayBuffer rooted by `self.buf` (set in `init`). - let key = unsafe { &mut *self.result }; - self.run_task_impl(key); + impl JobContext for ScryptJob { + type OffThread = Self; + type Js = ScryptJs; + + fn run( + this: &mut Self, + vm: &Borrow, + done: bun_jsc::Completion, + ) -> Option> { + // SAFETY: `result` is `buf`'s backing store (kept by the Js side); VM alive under the borrow. + let key = unsafe { this.result.under_borrow(vm) }; + this.err = this.params.run_task_impl(key); + Some(done) } - fn run_from_js(&mut self, global: &JSGlobalObject, callback: JSValue) { - // a self-ptr live for the VM lifetime. Short-lived `&mut` formed at use site - // per VirtualMachine.rs §event_loop contract. + fn then(this: Self, js: ScryptJs, cx: &JsThread<'_>) -> JsResult<()> { + let global = cx.global(); let event_loop = global.bun_vm().event_loop_mut(); + let callback = js.callback.get(); - if let Some(err) = self.err { - if err != 0 { + if let Some(err) = this.err { + let exception = if err != 0 { let mut buf = [0u8; 256]; - // SAFETY: buf is a valid 256-byte buffer; ERR_error_string_n - // NUL-terminates within `len` bytes and returns `buf`. + // SAFETY: buf is a valid writable buffer of the given length. unsafe { boringssl::c::ERR_error_string_n(err, buf.as_mut_ptr().cast(), buf.len()) }; - // SAFETY: `buf` is NUL-terminated by the call above. + // SAFETY: ERR_error_string_n always NUL-terminates within `buf`. let msg = unsafe { bun_core::ffi::cstr(buf.as_ptr().cast()) }; - let exception = global + global .err( ErrorCode::CRYPTO_OPERATION_FAILED, format_args!("Scrypt failed: {}", bstr::BStr::new(msg.to_bytes())), ) - .to_js(); - event_loop.run_callback(callback, global, JSValue::UNDEFINED, &[exception]); - return; - } - - let exception = global - .err( - ErrorCode::CRYPTO_OPERATION_FAILED, - format_args!("Scrypt failed"), - ) - .to_js(); + .to_js() + } else { + global + .err( + ErrorCode::CRYPTO_OPERATION_FAILED, + format_args!("Scrypt failed"), + ) + .to_js() + }; event_loop.run_callback(callback, global, JSValue::UNDEFINED, &[exception]); - return; + return Ok(()); } - let buf = self.buf.swap(); event_loop.run_callback( callback, global, JSValue::UNDEFINED, - &[JSValue::UNDEFINED, buf], + &[JSValue::UNDEFINED, js.buf.get()], ); - } - - fn deinit(&mut self) { - // `Drop for StringOrBuffer` releases salt/password when `CryptoJob` is freed. - bun_jsc::Unprotect::unprotect(self); - self.buf.deinit(); + Ok(()) } } @@ -1202,11 +1125,7 @@ mod _impl { fn pbkdf2(global_this: &JSGlobalObject, call_frame: &CallFrame) -> JsResult { let data = PBKDF2::from_js(global_this, call_frame, true)?; - let job = pbkdf2::create_job(global_this, data); - // SAFETY: `job` was just boxed by `create()` and is live; `ctx.promise` is - // not touched by the off-thread `run` body, and the JS-thread completion - // cannot run until this host fn returns. - Ok(unsafe { (*job).ctx.promise.value() }) + Ok(pbkdf2::create_job(global_this, data)) } #[bun_jsc::host_fn] @@ -1345,17 +1264,35 @@ mod _impl { #[bun_jsc::host_fn] fn scrypt(global: &JSGlobalObject, call_frame: &CallFrame) -> JsResult { let (ctx, callback) = Scrypt::from_js::(global, call_frame)?; - crypto_job_init_and_schedule(global, callback, ctx)?; + // Protected by `from_js::`; released with the job wherever it ends. + let params = bun_jsc::ThreadSafe::adopt(ctx); + if params.keylen as usize > jsc::virtual_machine::synthetic_allocation_limit() { + return Err(global.throw_out_of_memory()); + } + let (buf, bytes) = ArrayBuffer::alloc::<{ JSType::ArrayBuffer }>(global, params.keylen)?; + let cx = global.js_thread(); + Job::::schedule( + &cx, + ScryptJob { + params, + // SAFETY: `bytes` is `buf`'s backing store, kept alive by the job's Js side. + result: unsafe { JsPtr::new(core::ptr::NonNull::from(bytes)) }, + err: None, + }, + ScryptJs { + callback: Strong::create(callback.with_async_context_if_needed(global), global), + buf: Strong::create(buf, global), + }, + ); Ok(JSValue::UNDEFINED) } #[bun_jsc::host_fn] fn scrypt_sync(global: &JSGlobalObject, call_frame: &CallFrame) -> JsResult { + // `password`/`salt` release on drop; nothing was protected on this path. let (ctx, _) = Scrypt::from_js::(global, call_frame)?; - let mut ctx = scopeguard::guard(ctx, |mut c| c.deinit_sync()); let (buf, bytes) = ArrayBuffer::alloc::<{ JSType::ArrayBuffer }>(global, ctx.keylen)?; - ctx.run_task_impl(bytes); - if ctx.err.is_some() { + if ctx.run_task_impl(bytes).is_some() { return Err(global .err( ErrorCode::CRYPTO_OPERATION_FAILED, diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index ced0457f206e..75e13e84411a 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -12,13 +12,11 @@ use crate::webcore; use bun_core::Environment; use bun_core::{String as BunString, ZStr}; use bun_event_loop::AnyTaskWithExtraContext::AnyTaskWithExtraContext; -use bun_event_loop::MiniEventLoop::MiniEventLoop; use bun_io::KeepAlive; use bun_jsc::AbortSignal; -use bun_jsc::EventLoopTaskPtr; use bun_jsc::debugger::AsyncTaskTracker; use bun_jsc::virtual_machine::VirtualMachine; -use bun_jsc::{EventLoopHandle, JSGlobalObject, JSValue, JsResult, Task, ThreadSafe, Unprotect}; +use bun_jsc::{EventLoopHandle, JSGlobalObject, JSValue, JsResult, ThreadSafe, Unprotect}; use bun_paths::{self as paths, OSPathBuffer, OSPathChar, OSPathSliceZ, PathBuffer}; use bun_sys::FdExt as _; use bun_sys::{self as sys, E, Fd as FD, Maybe, Mode, SystemErrno}; @@ -134,31 +132,7 @@ fn to_sys_time_like(t: super::time_like::TimeLike) -> sys::TimeLike { nsec: t.tv_nsec as i64, } } -// Local namespace shim: dependents in this file spell `ConcurrentTask::create*`. -// The Rust crate exports the *struct* as `ConcurrentTask` -// inside a same-named module, so re-export the free constructors here under the -// module name the call sites expect. -mod ConcurrentTask { - pub(super) use bun_event_loop::ConcurrentTask::ConcurrentTask; - use core::ptr::NonNull; - #[inline] - pub(super) fn create(task: bun_jsc::Task) -> NonNull { - ConcurrentTask::create(task) - } - #[inline] - pub(super) fn create_from( - task: *mut T, - ) -> NonNull { - ConcurrentTask::create_from(task) - } - #[inline] - pub(super) fn from_callback( - ptr: *mut T, - cb: fn(*mut T) -> bun_event_loop::JsResult<()>, - ) -> NonNull { - ConcurrentTask::from_callback(ptr, cb) - } -} +use bun_event_loop::ConcurrentTask; /// `webcore.Blob.SizeType` — logically a 52-bit unsigned integer. /// Rust has no native `u52`, so the *storage* width is `u64`, but **never** use @@ -591,8 +565,6 @@ mod _async_tasks { const _: () = assert!(ReadFile::HAVE_ABORT_SIGNAL); const _: () = assert!(WriteFile::HAVE_ABORT_SIGNAL); - pub(crate) type ReaddirRecursive = AsyncReaddirRecursiveTask; - #[cfg(windows)] /// Used internally. Not from JavaScript. pub struct AsyncMkdirp { @@ -876,7 +848,7 @@ mod _async_tasks { task.global_object() .bun_vm() .event_loop_mut() - .enqueue_task(Task::init(task_ptr)); + .enqueue_task(bun_jsc::Task::init(task_ptr)); return task.promise.value(); } let pos: i64 = args.position.map(|p| p as i64).unwrap_or(-1); @@ -945,7 +917,7 @@ mod _async_tasks { this.global_object() .bun_vm() .event_loop_mut() - .enqueue_task(Task::init(this_ptr)); + .enqueue_task(bun_jsc::Task::init(this_ptr)); } extern "C" fn uv_callbackreq(req: *mut uv::fs_t) { @@ -967,7 +939,7 @@ mod _async_tasks { this.global_object() .bun_vm() .event_loop_mut() - .enqueue_task(Task::init(this_ptr)); + .enqueue_task(bun_jsc::Task::init(this_ptr)); } pub(crate) fn run_from_js_thread(&mut self) -> Result<(), bun_jsc::JsTerminated> { @@ -985,13 +957,13 @@ mod _async_tasks { Err(err) => match err.to_js_with_async_stack(global_object, promise) { Ok(v) => v, Err(e) => { - return promise.reject(global_object, Ok(global_object.take_exception(e))); + return promise.reject(global_object, Err(e)); } }, Ok(res) => match FsReturn::fs_to_js(res, global_object) { Ok(v) => v, Err(e) => { - return promise.reject(global_object, Ok(global_object.take_exception(e))); + return promise.reject(global_object, Err(e)); } }, }; @@ -1238,153 +1210,91 @@ mod _async_tasks { } } - /// `Taskable` glue so `ConcurrentTask::create_from(this)` resolves on the - /// generic `AsyncFSTask`. The const- - /// generic `F` carries the task tag and `NodeFSFunctionEnum::task_tag()` - /// is `const fn`, so the per-`F` tag is computed at monomorphisation time. - impl bun_event_loop::Taskable - for AsyncFSTask - { - const TAG: bun_event_loop::TaskTag = F.task_tag(); - } + /// `Taskable` glue for the libuv-request ops (Windows), which complete on + /// the JS thread and re-enter through the task queue under a per-`F` tag. #[cfg(windows)] - impl bun_event_loop::Taskable + impl bun_event_loop::Taskable for UVFSRequest + where + Op<{ F }>: NodeFSDispatch, { const TAG: bun_event_loop::TaskTag = F.task_tag(); + /// A libuv fs request that completed into the queue after the last + /// tick: destroy releases its promise handle and keep-alive. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — `Box::leak`'d in `UVFSRequest::create`. + unsafe { Self::destroy(this) } + } } + /// One `fs.promises.*` operation on the work pool. The arguments' JS-backed + /// buffers are protected (`ThreadSafe`) and read under the pool's VM borrow. pub struct AsyncFSTask { - pub(crate) promise: JSPromiseStrong, - /// Wrapped in [`ThreadSafe`] so the paired `unprotect()` runs on drop. pub args: ThreadSafe, - pub(crate) global_object: bun_ptr::BackRef, - pub task: WorkPoolTask, pub(crate) result: Maybe, - pub(crate) r#ref: KeepAlive, - pub(crate) tracker: AsyncTaskTracker, } + // SAFETY: results are plain data / owned buffers / WTF strings built off + // thread for hand-off (`ret::*`); `ThreadSafe` is Send by its contract. + unsafe impl Send for AsyncFSTask {} - bun_threading::intrusive_work_task!([R, A: Unprotect, const F: NodeFSFunctionEnum] AsyncFSTask, task); + /// The JS-thread half of an async fs operation. + #[derive(bun_jsc::JsAffine)] + pub struct AsyncFSJs { + pub(crate) promise: JSPromiseStrong, + pub(crate) tracker: AsyncTaskTracker, + } - impl AsyncFSTask + impl + bun_jsc::JobContext for AsyncFSTask where Op<{ F }>: NodeFSDispatch, { - /// NewAsyncFSTask supports cancelable operations via AbortSignal, - /// so long as a "signal" field exists. The task wrapper will ensure - /// a promise rejection happens if signaled, but if `function` is - /// already called, no guarantees are made. It is recommended for - /// the functions to check .signal.aborted() for early returns. - pub(crate) const HAVE_ABORT_SIGNAL: bool = A::HAVE_ABORT_SIGNAL; - - /// Deref the raw `global_object` pointer. - /// - /// Invariant: set from a live `&JSGlobalObject` in `create()` and never - /// null; the JSC global outlives every task (JSC_BORROW per LIFETIMES.tsv). - /// Safe to call from the work-pool thread for `bun_vm_concurrently()`. - #[inline] - pub(crate) fn global_object(&self) -> &JSGlobalObject { - self.global_object.get() - } - - pub(crate) fn create( - global_object: &JSGlobalObject, - _binding: &Binding, - args: A, - vm: &mut VirtualMachine, - ) -> JSValue { - let mut task = Box::new(Self { - promise: JSPromiseStrong::init(global_object), - args: args.into_thread_safe(), - // Sentinel — overwritten by `work_pool_callback` before any read on - // the JS thread. `Maybe` is `Result` and may be - // niche-optimised; never construct an all-zero `Result` value. - result: Err(sys::Error::default()), - global_object: bun_ptr::BackRef::new(global_object), - task: work_pool_task(Self::work_pool_callback), - r#ref: KeepAlive::default(), - tracker: AsyncTaskTracker::init(vm), - }); - // KeepAlive::ref_ now takes the type-erased aio EventLoopCtx; the JS - // event loop is the only one that owns AsyncFSTask/UVFSRequest. - task.r#ref.ref_(bun_io::js_vm_ctx()); - task.tracker.did_schedule(global_object); - let promise = task.promise.value(); - // Counted so shutdown's `wait_for_concurrent_posters` covers the - // work-pool completion post; paired in `work_pool_callback`. - // SAFETY: `event_loop()` is a value field of the live `vm`. - unsafe { (*vm.event_loop()).concurrent_poster_begin() }; - WorkPool::schedule(&raw mut bun_core::heap::release(task).task); - promise - } - - fn work_pool_callback(task: *mut WorkPoolTask) { - // SAFETY: `task` points to `Self.task` (container-of). - let this = unsafe { Self::from_task_ptr(task) }; - + type OffThread = Self; + type Js = AsyncFSJs; + + fn run( + this: &mut Self, + _vm: &bun_jsc::vm_handle::Borrow, + done: bun_jsc::Completion, + ) -> Option> { let mut node_fs = NodeFS::default(); - // SAFETY: `this` is the live Box-leaked task; the work-pool thread owns - // it exclusively until the enqueue below hands it to the JS thread. - // `args` and `result` are disjoint fields. - unsafe { - (*this).result = - NodeFS::dispatch::(&mut node_fs, &(*this).args, Flavor::Async); - } - // `sys::Error::path` is `Box<[u8]>` boxed at the - // `errno_sys_p` construction site, so no clone is needed — `node_fs` may drop. - - // `bun_vm_concurrently()` skips the JS-thread debug assert and is the - // documented accessor for off-thread (work-pool) callers; the - // event-loop's concurrent queue is MPSC-safe. - // SAFETY: `this` is still exclusively owned here (see above). - let vm = unsafe { (*this).global_object().bun_vm_concurrently() }; - // SAFETY: VirtualMachine and its event loop are process-static - // (LIFETIMES.tsv); the concurrent queue is MPSC-safe. Ownership of - // `this` transfers to the JS thread here — no use after this call. - unsafe { - (*(*vm).event_loop()).enqueue_task_concurrent(ConcurrentTask::create_from(this)); - // Pairs with `concurrent_poster_begin` in `create()`. The JS thread may free `this` - // once popped and tear the VM down at zero — this is the pool thread's last touch. - (*(*vm).event_loop()).concurrent_poster_end(); - } - } - - pub(crate) fn run_from_js_thread(&mut self) -> Result<(), bun_jsc::JsTerminated> { - // SAFETY: self was Box::leak'd in create(); destroy() runs exactly once on scope exit - let _deinit = scopeguard::guard(std::ptr::from_mut::(self), |p| unsafe { - Self::destroy(p) - }); - // Move `result` out so the `global_object()` `&self` borrow can coexist - // with `&mut result` below; the sentinel left behind is dropped in `destroy()`. - let mut result = core::mem::replace(&mut self.result, Err(sys::Error::default())); - let global_object = self.global_object(); - - let _dispatch = self.tracker.dispatch(global_object); - - let success = result.is_ok(); - let promise_value = self.promise.value(); - let promise = self.promise.get(); - let result = match &mut result { + this.result = NodeFS::dispatch::(&mut node_fs, &this.args, Flavor::Async); + // `sys::Error::path` is `Box<[u8]>` boxed at the `errno_sys_p` + // construction site, so no clone is needed — `node_fs` may drop. + Some(done) + } + + fn then( + mut this: Self, + js: AsyncFSJs, + cx: &bun_jsc::JsThread<'_>, + ) -> bun_jsc::JsResult<()> { + let global_object = cx.global(); + let _dispatch = js.tracker.dispatch(global_object); + + let success = this.result.is_ok(); + let promise_value = js.promise.value(); + let promise = js.promise.get(); + let result = match &mut this.result { Err(err) => match err.to_js_with_async_stack(global_object, promise) { Ok(v) => v, Err(e) => { - return promise.reject(global_object, Ok(global_object.take_exception(e))); + return Ok(promise.reject(global_object, Err(e))?); } }, Ok(res) => match FsReturn::fs_to_js(res, global_object) { Ok(v) => v, Err(e) => { - return promise.reject(global_object, Ok(global_object.take_exception(e))); + return Ok(promise.reject(global_object, Err(e))?); } }, }; promise_value.ensure_still_alive(); if Self::HAVE_ABORT_SIGNAL { - if let Some(signal) = self.args.signal() { + if let Some(signal) = this.args.signal() { if let Some(abort_error) = signal.node_abort_error_if_aborted(global_object) { - return promise.reject(global_object, Ok(abort_error)); + return Ok(promise.reject(global_object, Ok(abort_error))?); } } } @@ -1396,14 +1306,41 @@ mod _async_tasks { } Ok(()) } + } - /// SAFETY: `this` must be the pointer Box::leak'd in `create()`; called exactly once. - pub(crate) unsafe fn destroy(this: *mut Self) { - // SAFETY: caller guarantees `this` is the live Box-leaked allocation; - // reclaim ownership (paired with the Box::leak in create()). - let mut task = unsafe { bun_core::heap::take(this) }; - // `bun_sys::Error` frees its path on Drop. - task.r#ref.unref(bun_io::js_vm_ctx()); + impl + AsyncFSTask + where + Op<{ F }>: NodeFSDispatch, + { + /// NewAsyncFSTask supports cancelable operations via AbortSignal, + /// so long as a "signal" field exists. The task wrapper will ensure + /// a promise rejection happens if signaled, but if `function` is + /// already called, no guarantees are made. It is recommended for + /// the functions to check .signal.aborted() for early returns. + pub(crate) const HAVE_ABORT_SIGNAL: bool = A::HAVE_ABORT_SIGNAL; + + pub(crate) fn create( + global_object: &JSGlobalObject, + _binding: &Binding, + args: A, + vm: &mut VirtualMachine, + ) -> JSValue { + let tracker = AsyncTaskTracker::init(vm); + tracker.did_schedule(global_object); + let promise = JSPromiseStrong::init(global_object); + let value = promise.value(); + bun_jsc::Job::::schedule( + &global_object.js_thread(), + Self { + args: args.into_thread_safe(), + // Sentinel — overwritten by `run` before any read. `Maybe` + // may be niche-optimised; never construct an all-zero `Result`. + result: Err(sys::Error::default()), + }, + AsyncFSJs { promise, tracker }, + ); + value } } @@ -1423,7 +1360,10 @@ mod _async_tasks { pub(crate) promise: JSPromiseStrong, /// Wrapped in [`ThreadSafe`] so the paired `unprotect()` runs on drop. pub args: ThreadSafe, + /// Owning-thread uses (global object, keep-alive context). pub(crate) evtloop: EventLoopHandle, + /// How the last subtask's thread delivers the completion. + pub(crate) poster: bun_jsc::ConcurrentPoster, pub task: WorkPoolTask, /// Written from any workpool thread (first `finish_concurrently` caller wins via /// `has_result` CAS); read on the JS thread in `run_from_js_thread`. Wrapped in @@ -1561,6 +1501,20 @@ mod _async_tasks { } } + impl bun_event_loop::Taskable for NewAsyncCpTask { + const TAG: bun_event_loop::TaskTag = if IS_SHELL { + bun_event_loop::task_tag::ShellAsyncCpTask + } else { + bun_event_loop::task_tag::AsyncCpTask + }; + /// A finished fs.cp whose completion will not run: destroy releases + /// its promise handle, protected arguments and keep-alive. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — posted by `on_subtask_done` with the count at zero. + unsafe { Self::destroy(this) } + } + } + impl NewAsyncCpTask { pub(crate) fn on_copy( &self, @@ -1578,85 +1532,67 @@ mod _async_tasks { .cp_on_copy(src.as_ref(), dest.as_ref()); } + /// `fs.cp` / `fs.promises.cp` (JS thread): a promise, an async-stack + /// tracker, and this VM's loop. pub(crate) fn create( global_object: &JSGlobalObject, _binding: &Binding, cp_args: args::Cp, vm: &mut VirtualMachine, ) -> JSValue { - let task = Self::create_with_shell_task( - global_object, + let tracker = AsyncTaskTracker::init(vm); + tracker.did_schedule(global_object); + let task = Self::schedule_new( + JSPromiseStrong::init(global_object), cp_args, - vm, + EventLoopHandle::init(vm.event_loop.cast()), + bun_jsc::ConcurrentPoster::Js(vm.js_poster()), + tracker, core::ptr::null_mut(), - true, ); - // SAFETY: create_with_shell_task returns a Box::leak'd pointer; valid until destroy() + // SAFETY: `schedule_new` returns a Box::leak'd pointer; valid until destroy() unsafe { &*task }.promise.value() } - pub(crate) fn create_with_shell_task( - global_object: &JSGlobalObject, + /// The shell's `cp` builtin, from its pool task (any thread): no VM or + /// global is touched — the loop and poster are the ones the shell task + /// already captured on its own thread. + pub(crate) fn create_for_shell( cp_args: args::Cp, - vm: &mut VirtualMachine, + evtloop: EventLoopHandle, + poster: bun_jsc::ConcurrentPoster, shelltask: *mut ShellCpTask, - enable_promise: bool, ) -> *mut Self { - let mut task = Box::new(Self { - promise: if enable_promise { - JSPromiseStrong::init(global_object) - } else { - JSPromiseStrong::default() - }, - args: cp_args.into_thread_safe(), - has_result: AtomicBool::new(false), - // Sentinel — overwritten by `finish_concurrently` (gated by the - // `has_result` CAS) before any read on the JS thread. - result: core::cell::Cell::new(Ok(())), - // `vm.event_loop` is the live per-thread `jsc::EventLoop` field. - evtloop: EventLoopHandle::init(vm.event_loop.cast()), - task: work_pool_task(Self::work_pool_callback), - r#ref: KeepAlive::default(), - tracker: AsyncTaskTracker::init(vm), - subtask_count: AtomicUsize::new(1), - // SAFETY: `shelltask` (when non-null) is the live heap-alloc'd `ShellCpTask` - // that owns and outlives this task; pointer carries write provenance. - shelltask: unsafe { bun_ptr::ParentRef::from_nullable_mut(shelltask) }, - }); - if !IS_SHELL { - task.r#ref.ref_(event_loop_handle_to_ctx(task.evtloop)); - } - task.tracker.did_schedule(global_object); - - // Counted so shutdown's `wait_for_concurrent_posters` covers the completion post; - // paired in `on_subtask_done`'s Js arm (the mini path never touches the JS event loop). - // SAFETY: `event_loop()` is a value field of the live `vm`. - unsafe { (*vm.event_loop()).concurrent_poster_begin() }; - let raw = bun_core::heap::release(task); - WorkPool::schedule(&raw mut raw.task); - raw + Self::schedule_new( + JSPromiseStrong::default(), + cp_args, + evtloop, + poster, + AsyncTaskTracker { id: 0 }, + shelltask, + ) } - pub(crate) fn create_mini( + fn schedule_new( + promise: JSPromiseStrong, cp_args: args::Cp, - // `EventLoopHandle::Mini` stores `*mut MiniEventLoop` (a - // non-owning erased backref, see `bun_event_loop::AnyEventLoop`). Taking the - // raw pointer here avoids forcing every caller's `MiniEventLoop` borrow to be - // `'static`; the task never outlives the loop. - mini: *mut MiniEventLoop, + evtloop: EventLoopHandle, + poster: bun_jsc::ConcurrentPoster, + tracker: AsyncTaskTracker, shelltask: *mut ShellCpTask, ) -> *mut Self { let mut task = Box::new(Self { - promise: JSPromiseStrong::default(), + promise, args: cp_args.into_thread_safe(), has_result: AtomicBool::new(false), // Sentinel — overwritten by `finish_concurrently` (gated by the // `has_result` CAS) before any read on the JS thread. result: core::cell::Cell::new(Ok(())), - evtloop: EventLoopHandle::init_mini(mini), + evtloop, + poster, task: work_pool_task(Self::work_pool_callback), r#ref: KeepAlive::default(), - tracker: AsyncTaskTracker { id: 0 }, + tracker, subtask_count: AtomicUsize::new(1), // SAFETY: `shelltask` (when non-null) is the live heap-alloc'd `ShellCpTask` // that owns and outlives this task; pointer carries write provenance. @@ -1665,6 +1601,9 @@ mod _async_tasks { if !IS_SHELL { task.r#ref.ref_(event_loop_handle_to_ctx(task.evtloop)); } + // Its arguments may point into JS buffers and its promise lives on + // the JS heap: counted, so the VM waits for it (embedded work). + task.poster.embedded_work_scheduled(); let raw = bun_core::heap::release(task); WorkPool::schedule(&raw mut raw.task); @@ -1730,38 +1669,35 @@ mod _async_tasks { // Count reached zero ⇒ exclusive access. `this` carries mutable // provenance from `Box::leak`, so the enqueued callback may safely // form `&mut *this` on the JS thread. - if let EventLoopHandle::Js { owner } = this_ref.evtloop { - this_ref.evtloop.enqueue_task_concurrent(EventLoopTaskPtr { - js: ConcurrentTask::from_callback(this, |p| { - // SAFETY: `p` is the `Box::leak`'d task; subtask count hit zero so this - // JS-thread callback holds the only live reference (exclusive `&mut`). - unsafe { (&mut *p).run_from_js_thread().map_err(Into::into) } - }) - .as_ptr(), - }); - // Pairs with `concurrent_poster_begin` in `create_with_shell_task`. The JS thread - // may free the task once popped and tear the VM down at zero — last touch of loop. - owner.concurrent_poster_end(); + let poster = this_ref.poster.clone(); + if poster.is_js() { + let ct = ConcurrentTask::ConcurrentTask::create(bun_jsc::Task::init(this)); + // Counted work: the VM has not closed its handle. + let bun_jsc::vm_handle::Posted::Queued = poster.post_js(ct) else { + unreachable!("VM handle closed with an fs.cp outstanding"); + }; } else { - this_ref.evtloop.enqueue_task_concurrent(EventLoopTaskPtr { - mini: AnyTaskWithExtraContext::from_callback_auto_deinit( - this, - |p: *mut Self, ctx| { - // SAFETY: subtask count hit zero ⇒ exclusive access to the leaked task. - unsafe { (*p).run_from_js_thread_mini(ctx) } - }, - ), - }); + let at = AnyTaskWithExtraContext::from_callback_auto_deinit( + this, + |p: *mut Self, ctx| { + // SAFETY: subtask count hit zero ⇒ exclusive access to the leaked task. + unsafe { (*p).run_from_js_thread_mini(ctx) } + }, + ); + // `from_callback_auto_deinit` heap-allocates; never null. + poster.post_mini(core::ptr::NonNull::new(at).expect("heap task")); } + // The pool side is done (`this` may already be freed by its loop). + poster.embedded_work_finished(); } pub(crate) fn run_from_js_thread_mini(&mut self, _: *mut c_void) { let _ = self.run_from_js_thread(); // TODO: properly propagate exception upwards } - fn run_from_js_thread(&mut self) -> Result<(), bun_jsc::JsTerminated> { + pub(crate) fn run_from_js_thread(&mut self) -> Result<(), bun_jsc::JsTerminated> { if IS_SHELL { - // SAFETY: shelltask is set by create_with_shell_task/create_mini and outlives this task + // SAFETY: shelltask is set by create_for_shell and outlives this task // Move the result out — `Maybe` (= `Maybe<()>`) has a cheap // `Ok(())` placeholder. let result = core::mem::replace(self.result.get_mut(), Ok(())); @@ -1794,8 +1730,7 @@ mod _async_tasks { Err(e) => { // SAFETY: `promise` points at a GC-rooted JS heap cell; sole live // reference on this thread (see comment above `let promise`). - return unsafe { &mut *promise } - .reject(global_object, Ok(global_object.take_exception(e))); + return unsafe { &mut *promise }.reject(global_object, Err(e)); } }, Ok(res) => match FsReturn::fs_to_js(res, global_object) { @@ -1803,8 +1738,7 @@ mod _async_tasks { Err(e) => { // SAFETY: `promise` points at a GC-rooted JS heap cell; sole live // reference on this thread (see comment above `let promise`). - return unsafe { &mut *promise } - .reject(global_object, Ok(global_object.take_exception(e))); + return unsafe { &mut *promise }.reject(global_object, Err(e)); } }, }; @@ -1823,11 +1757,11 @@ mod _async_tasks { } /// SAFETY: `this` must be the pointer returned by Box::leak in - /// `create_with_shell_task()`/`create_mini()`; called exactly once. + /// `schedule_new()`; called exactly once. pub(crate) unsafe fn destroy(this: *mut Self) { // SAFETY: caller guarantees `this` is the live Box-leaked allocation; // reclaim ownership (paired with the Box::leak in - // create_with_shell_task()/create_mini()). + // schedule_new()). let mut task = unsafe { bun_core::heap::take(this) }; if !IS_SHELL { let ctx = event_loop_handle_to_ctx(task.evtloop); @@ -2194,14 +2128,18 @@ mod _async_tasks { // AsyncReaddirRecursiveTask // ────────────────────────────────────────────────────────────────────────── + /// `readdir(.., { recursive: true })`: a scan fanned out over pool subtasks + /// that share this state (it is the job's off-thread part, so its address + /// is stable while any subtask runs). Subtasks touch only owned data here — + /// never the JS-backed `args` — since they run outside the VM borrow. pub struct AsyncReaddirRecursiveTask { - pub(crate) promise: JSPromiseStrong, - /// Wrapped in [`ThreadSafe`] so the paired `unprotect()` runs on drop. + /// Protected arguments; their JS-backed path is not read off-thread + /// (`root_path` is the owned copy). pub args: ThreadSafe, - pub(crate) global_object: bun_ptr::BackRef, - pub task: WorkPoolTask, - pub(crate) r#ref: KeepAlive, - pub(crate) tracker: AsyncTaskTracker, + pub(crate) tag: ret::ReaddirTag, + pub(crate) encoding: Encoding, + /// The completion token, finished by whichever subtask ends the scan. + pub(crate) done: Option>, // It's not 100% clear this one is necessary pub(crate) has_result: AtomicBool, @@ -2229,8 +2167,87 @@ mod _async_tasks { pub(crate) pending_err: Option, pub(crate) pending_err_mutex: bun_threading::Mutex, } + // SAFETY: shared by the pool subtasks through atomics / the lock-free + // queue / the mutex; `args` is Send by `ThreadSafe`'s contract; results + // are owned buffers and WTF strings built off-thread for hand-off. + unsafe impl Send for AsyncReaddirRecursiveTask {} + + impl Drop for AsyncReaddirRecursiveTask { + fn drop(&mut self) { + debug_assert!(self.root_fd == FD::INVALID, "scan still owns its root fd"); + self.clear_result_list(); + } + } - bun_threading::intrusive_work_task!(AsyncReaddirRecursiveTask, task); + impl bun_jsc::JobContext for AsyncReaddirRecursiveTask { + type OffThread = Self; + type Js = AsyncFSJs; + + fn run( + this: &mut Self, + _vm: &bun_jsc::vm_handle::Borrow, + done: bun_jsc::Completion, + ) -> Option> { + this.done = Some(done); + let mut buf = PathBuffer::uninit(); + let root_path_z = { + let bytes: &'static [u8] = + // SAFETY: `root_path` is a NUL-terminated `Box<[u8]>` fixed for the + // task's lifetime; `perform_work` mutates other fields only. + unsafe { bun_ptr::detach_lifetime(&this.root_path[..]) }; + ZStr::from_buf(bytes, bytes.len() - 1) + }; + // May finish synchronously (no subdirectories) or fan out; the last + // subtask finishes the token. + this.perform_work(root_path_z, &mut buf, true); + None + } + + fn then( + mut this: Self, + js: AsyncFSJs, + cx: &bun_jsc::JsThread<'_>, + ) -> bun_jsc::JsResult<()> { + let global_object = cx.global(); + let success = this.pending_err.is_none(); + let promise_value = js.promise.value(); + let promise = js.promise.get(); + let result = if let Some(err) = &mut this.pending_err { + match err.to_js_with_async_stack(global_object, promise) { + Ok(v) => v, + Err(e) => { + return Ok(promise.reject(global_object, Err(e))?); + } + } + } else { + let res = match core::mem::replace( + &mut this.result_list, + ResultListEntryValue::Files(Vec::new()), + ) { + ResultListEntryValue::WithFileTypes(v) => { + ret::Readdir::WithFileTypes(v.into_boxed_slice()) + } + ResultListEntryValue::Buffers(v) => ret::Readdir::Buffers(v.into_boxed_slice()), + ResultListEntryValue::Files(v) => ret::Readdir::Files(v.into_boxed_slice()), + }; + match res.to_js(global_object) { + Ok(v) => v, + Err(e) => { + return Ok(promise.reject(global_object, Err(e))?); + } + } + }; + promise_value.ensure_still_alive(); + let _dispatch = js.tracker.dispatch(global_object); + drop(this); + if success { + promise.resolve(global_object, result)?; + } else { + promise.reject(global_object, Ok(result))?; + } + Ok(()) + } + } pub enum ResultListEntryValue { WithFileTypes(Vec), @@ -2317,29 +2334,6 @@ mod _async_tasks { } impl AsyncReaddirRecursiveTask { - pub(crate) fn new(init: Self) -> Box { - Box::new(init) - } - - /// Borrow the owning `JSGlobalObject`. - /// - /// SAFETY: `global_object` is set from a live `&JSGlobalObject` in - /// `create()` (never null) and the JSC_BORROW invariant (LIFETIMES.tsv) - /// guarantees the global outlives every task it spawns. The pointee is a - /// pinned JSC heap object; `bun_vm_concurrently()` is the only method we - /// call off-thread and it reads init-immutable state, so a shared borrow - /// is sound from both the JS thread and the work pool. - #[inline] - pub(crate) fn global_object(&self) -> &JSGlobalObject { - self.global_object.get() - } - - /// Free `root_path` — paired with the NUL-terminated duplication in - /// `create()`. Idempotent (empty `Box` after first call). - fn free_root_path(&mut self) { - drop(core::mem::take(&mut self.root_path)); - } - pub(crate) fn enqueue(&mut self, basename: &ZStr) { // The subtask runs on another thread after the caller's `name_to_copy_z` // (which points into a per-iteration buffer) has been overwritten, so we @@ -2371,50 +2365,46 @@ mod _async_tasks { args: args::Readdir, vm: &mut VirtualMachine, ) -> JSValue { - let result_list = match args.tag() { + let tag = args.tag(); + let encoding = args.encoding; + let result_list = match tag { ret::ReaddirTag::Files => ResultListEntryValue::Files(Vec::new()), ret::ReaddirTag::WithFileTypes => ResultListEntryValue::WithFileTypes(Vec::new()), ret::ReaddirTag::Buffers => ResultListEntryValue::Buffers(Vec::new()), }; - // The - // subtasks read `root_path` (NUL-terminated) from the work pool after - // `args.to_thread_safe()` may have rehomed the original slice, so we - // must own a NUL-terminated copy. Freed in `finish_concurrently()` or - // `destroy()` via `free_root_path()`. + // Subtasks read the root path outside the VM borrow, so it must be an + // owned copy rather than the (possibly JS-backed) argument. NUL-terminated. let root_path = { let src = args.path.slice(); let mut owned = Vec::with_capacity(src.len() + 1); owned.extend_from_slice(src); owned.push(0); - // NUL-terminated `[bytes.., 0]`; freed on drop / `free_root_path()`. owned.into_boxed_slice() }; - let mut task = Self::new(AsyncReaddirRecursiveTask { - promise: JSPromiseStrong::init(global_object), - args: FsArgument::into_thread_safe(args), - has_result: AtomicBool::new(false), - global_object: bun_ptr::BackRef::new(global_object), - task: work_pool_task(Self::work_pool_callback), - r#ref: KeepAlive::default(), - tracker: AsyncTaskTracker::init(vm), - subtask_count: AtomicUsize::new(1), - root_path, - result_list, - result_list_count: AtomicUsize::new(0), - result_list_queue: UnboundedQueue::default(), - root_fd: FD::INVALID, - pending_err: None, - pending_err_mutex: bun_threading::Mutex::default(), - }); - task.r#ref.ref_(bun_io::js_vm_ctx()); - task.tracker.did_schedule(global_object); - let promise = task.promise.value(); - // Counted so shutdown's `wait_for_concurrent_posters` covers the - // single CAS-gated completion post; paired in `finish_concurrently`. - // SAFETY: `event_loop()` is a value field of the live `vm`. - unsafe { (*vm.event_loop()).concurrent_poster_begin() }; - WorkPool::schedule(&raw mut bun_core::heap::release(task).task); - promise + let tracker = AsyncTaskTracker::init(vm); + tracker.did_schedule(global_object); + let promise = JSPromiseStrong::init(global_object); + let value = promise.value(); + bun_jsc::Job::::schedule( + &global_object.js_thread(), + AsyncReaddirRecursiveTask { + args: FsArgument::into_thread_safe(args), + tag, + encoding, + done: None, + has_result: AtomicBool::new(false), + subtask_count: AtomicUsize::new(1), + root_path, + result_list, + result_list_count: AtomicUsize::new(0), + result_list_queue: UnboundedQueue::default(), + root_fd: FD::INVALID, + pending_err: None, + pending_err_mutex: bun_threading::Mutex::default(), + }, + AsyncFSJs { promise, tracker }, + ); + value } pub(crate) fn perform_work( @@ -2423,11 +2413,6 @@ mod _async_tasks { buf: &mut PathBuffer, is_root: bool, ) { - // SAFETY: `readdir_with_entries_recursive_async` takes `args` and - // `async_task` separately even though `args == &async_task.args`. The - // callee never mutates `args` (only `async_task.{root_fd, enqueue}`), - // so erase the field borrow through a raw pointer to satisfy borrowck. - let args_ptr: *const args::Readdir = &raw const *self.args; macro_rules! impl_tag { ($T:ty, $variant:ident) => {{ // A bare `Vec::new()` here @@ -2441,9 +2426,6 @@ mod _async_tasks { Vec::with_capacity(8192usize / core::mem::size_of::<$T>()); let res = NodeFS::readdir_with_entries_recursive_async::<$T>( buf, - // SAFETY: `args_ptr` was derived from `&*self.args` above; the boxed - // `self.args` outlives this call and is only read here. - unsafe { &*args_ptr }, self, basename, &mut entries, @@ -2460,7 +2442,7 @@ mod _async_tasks { let err_path: &[u8] = if !err.path.is_empty() { &err.path[..] } else { - self.args.path.slice() + &self.root_path[..self.root_path.len() - 1] }; self.pending_err = Some(err.with_path(err_path)); } @@ -2475,36 +2457,13 @@ mod _async_tasks { } }}; } - match self.args.tag() { + match self.tag { ret::ReaddirTag::Files => impl_tag!(BunString, Files), ret::ReaddirTag::WithFileTypes => impl_tag!(Dirent, WithFileTypes), ret::ReaddirTag::Buffers => impl_tag!(Buffer, Buffers), } } - fn work_pool_callback(task: *mut WorkPoolTask) { - // SAFETY: `task` points to `Self.task` (container-of). - let this = unsafe { Self::from_task_ptr(task) }; - let mut buf = PathBuffer::uninit(); - // `root_path` backing is fixed for the task's lifetime and only - // `perform_work`'s callee reads it (it mutates other fields), so - // detach the field borrow to satisfy borrowck (mirrors the - // `perform_work` body's own `args_ptr` erase, and line ~6623). - let root_path_z = { - // SAFETY: `root_path` is a NUL-terminated `Box<[u8]>` set in - // `create()` and not reallocated for the task's lifetime; shared - // field borrow only. - let root_path: &[u8] = unsafe { &(*this).root_path }; - // SAFETY: see above — the backing bytes outlive this call. - let bytes: &'static [u8] = unsafe { bun_ptr::detach_lifetime(root_path) }; - ZStr::from_buf(bytes, bytes.len() - 1) - }; - // SAFETY: `this` is the live Box-leaked task; this scan task holds one - // `subtask_count` reference, so the JS thread cannot free it during - // the call. The `&mut` is scoped to the call. - unsafe { (*this).perform_work(root_path_z, &mut buf, true) }; - } - pub(crate) fn write_results(&mut self, result: &mut Vec) { if !result.is_empty() { // `result` is already a heap `Vec`, so cloning would be a redundant @@ -2547,7 +2506,6 @@ mod _async_tasks { use bun_sys::FdExt as _; self.root_fd = FD::INVALID; root_fd.close(); - self.free_root_path(); } if self.pending_err.is_some() { @@ -2584,22 +2542,9 @@ mod _async_tasks { } } - // `bun_vm_concurrently()` skips the JS-thread debug assert and is the - // documented accessor for off-thread (work-pool) callers. - let vm = self.global_object().bun_vm_concurrently(); - // `ConcurrentTask::create` heap-allocates a fresh task; the - // queue takes ownership of it. - // SAFETY: `vm` is the process-singleton VM (LIFETIMES.tsv); the - // concurrent queue is MPSC-safe and the borrow is scoped to the call. - unsafe { - (*vm).enqueue_task_concurrent(ConcurrentTask::create(Task::init( - std::ptr::from_mut::(self), - ))); - } - // Pairs with `concurrent_poster_begin` in `create()`. The JS thread may free `self` - // once popped and tear the VM down at zero — last touch of both. - // SAFETY: `event_loop()` is a value field of the process-static VM. - unsafe { (*(*vm).event_loop()).concurrent_poster_end() }; + // Hand the scan back to its VM (or, if that is gone, to the release + // that frees this off-thread part). Last touch of `self` on this thread. + self.done.take().expect("scan finished twice").finish(); } fn clear_result_list(&mut self) { @@ -2626,80 +2571,6 @@ mod _async_tasks { } self.result_list_count.store(0, Ordering::Relaxed); } - - pub(crate) fn run_from_js_thread(&mut self) -> Result<(), bun_jsc::JsTerminated> { - // NOTE: cannot route through `self.global_object()` here -- the returned - // borrow would be tied to `&self` and conflict with the `&mut self.*` - // field accesses below, and it must also stay valid past `Self::destroy`. - // BackRef is `Copy`; copy it to a local so the borrow is detached from `self`. - let global_object = self.global_object; - let global_object = global_object.get(); - let success = self.pending_err.is_none(); - let promise_value = self.promise.value(); - // Raw-pointer capture: see `AsyncCpTask::run_from_js_thread` for rationale — - // `Self::destroy` must run before resolve/reject, and the `JSPromise` cell - // outlives the `Strong` wrapper via `promise_value.ensure_still_alive()`. - let promise: *mut bun_jsc::JSPromise = self.promise.get(); - let result = if let Some(err) = &mut self.pending_err { - // SAFETY: `promise` is the sole live reference to the heap `JSPromise`. - match err.to_js_with_async_stack(global_object, unsafe { &*promise }) { - Ok(v) => v, - Err(e) => { - // SAFETY: `promise` points at a GC-rooted JS heap cell; sole live - // reference on this thread (see comment above `let promise`). - return unsafe { &mut *promise } - .reject(global_object, Ok(global_object.take_exception(e))); - } - } - } else { - let res = match core::mem::replace( - &mut self.result_list, - ResultListEntryValue::Files(Vec::new()), - ) { - ResultListEntryValue::WithFileTypes(v) => { - ret::Readdir::WithFileTypes(v.into_boxed_slice()) - } - ResultListEntryValue::Buffers(v) => ret::Readdir::Buffers(v.into_boxed_slice()), - ResultListEntryValue::Files(v) => ret::Readdir::Files(v.into_boxed_slice()), - }; - match res.to_js(global_object) { - Ok(v) => v, - Err(e) => { - // SAFETY: `promise` points at a GC-rooted JS heap cell; sole live - // reference on this thread (see comment above `let promise`). - return unsafe { &mut *promise } - .reject(global_object, Ok(global_object.take_exception(e))); - } - } - }; - promise_value.ensure_still_alive(); - - let _dispatch = self.tracker.dispatch(global_object); - - // SAFETY: self was Box::leak'd in create(); destroyed exactly once here - unsafe { Self::destroy(std::ptr::from_mut::(self)) }; - if success { - bun_jsc::JSPromise::opaque_mut(promise).resolve(global_object, result)?; - } else { - bun_jsc::JSPromise::opaque_mut(promise).reject(global_object, Ok(result))?; - } - Ok(()) - } - - /// SAFETY: `this` must be the pointer Box::leak'd in `create()`; called exactly once. - pub(crate) unsafe fn destroy(this: *mut Self) { - // SAFETY: caller guarantees `this` is the live Box-leaked allocation; - // reclaim ownership (paired with the Box::leak in create()). - let mut task = unsafe { bun_core::heap::take(this) }; - debug_assert!(task.root_fd == FD::INVALID); // should already have closed it - // `bun_sys::Error` frees on Drop; nothing to do. - let _ = task.pending_err.take(); - // `KeepAlive::unref` takes the type-erased - // `EventLoopCtx`. Resolve via the global JS-loop hook (single JS thread). - task.r#ref.unref(bun_io::js_vm_ctx()); - task.free_root_path(); - task.clear_result_list(); - } } /// Maps a readdir element type to its `ResultListEntryValue` variant. @@ -2725,13 +2596,6 @@ mod _async_tasks { } } - // Route `Task::init(self)` in `finish_concurrently` to the event-loop dispatch - // table. The `task_tag::ReaddirRecursive` arm is wired in - // `crate::dispatch::run_task` to call `run_from_js_thread`. - impl bun_event_loop::Taskable for AsyncReaddirRecursiveTask { - const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::ReaddirRecursive; - } - impl ResultListEntryValue { fn from_vec(v: Vec) -> Self { T::into_variant(v) @@ -4676,17 +4540,14 @@ pub mod ret { // items dropped here (auto free) Ok(array) } - Readdir::Buffers(items) => { + Readdir::Buffers(mut items) => { // Node returns `Buffer[]` for `{ encoding: "buffer" }`, not // `Uint8Array[]`. Ownership of every `Buffer`'s bytes // transfers to JSC via `to_node_buffer`; the boxed slice // itself is freed when `items` drops. let array = JSValue::create_empty_array(global_object, items.len())?; - for (i, item) in items.iter().enumerate() { - let res = item.to_node_buffer(global_object); - if res == JSValue::ZERO { - return Ok(JSValue::ZERO); - } + for (i, item) in items.iter_mut().enumerate() { + let res = item.to_node_buffer(global_object)?; array.put_index(global_object, i as u32, res)?; } Ok(array) @@ -6598,7 +6459,6 @@ impl NodeFS { pub(crate) fn readdir_with_entries_recursive_async( buf: &mut PathBuffer, - args: &args::Readdir, async_task: &mut AsyncReaddirRecursiveTask, basename: &ZStr, entries: &mut Vec, @@ -6656,7 +6516,7 @@ impl NodeFS { return Err(err.with_path(joined.as_bytes())); } } - return Err(err.with_path(args.path.slice())); + return Err(err.with_path(root_basename)); } Ok(fd_) => fd_, }; @@ -6689,7 +6549,7 @@ impl NodeFS { ); return Err(err.with_path(joined.as_bytes())); } - return Err(err.with_path(args.path.slice())); + return Err(err.with_path(root_basename)); } Ok(None) => break, Ok(Some(ent)) => ent, @@ -6771,7 +6631,7 @@ impl NodeFS { name_to_copy, &dirent_path_prev, effective_kind, - args.encoding, + async_task.encoding, false, ); } @@ -7321,12 +7181,18 @@ impl NodeFS { // (per-thread singleton; see `pipe_read_buffer` // above) — `BackRef` invariant holds. let global = vm.global(); - let array_buffer = bun_jsc::ArrayBuffer::create_buffer( + let Ok(array_buffer) = bun_jsc::ArrayBuffer::create_buffer( global, temporary_read_buffer_before_stat_call, - ) - // TODO: properly propagate exception upwards - .unwrap_or(JSValue::ZERO); + ) else { + // OOM / a termination request: that JS exception + // is pending and wins — the binding's `throw_value` + // yields to it and drops this errno. + return Err(with_path_like( + sys::Error::from_code(E::ENOMEM, sys::Tag::read), + &args.path, + )); + }; array_buffer.ensure_still_alive(); return match array_buffer.as_array_buffer(global) { Some(buffer) => Ok(ret::ReadFileWithOptions::Buffer( @@ -10337,52 +10203,21 @@ pub enum NodeFSFunctionEnum { } impl NodeFSFunctionEnum { - /// Maps each async-FS function to its event-loop [`TaskTag`] (the `tags!` - /// macro in `bun_event_loop::task_tag` declares one constant per variant). + /// The event-loop [`TaskTag`] of the ops that are libuv requests on + /// Windows (`UVFSRequest`) and so re-enter through the task queue; every + /// other async op is a `bun_jsc::Job` and needs none. + #[cfg(windows)] pub const fn task_tag(self) -> bun_event_loop::TaskTag { use bun_event_loop::task_tag; match self { - Self::Access => task_tag::Access, - Self::AppendFile => task_tag::AppendFile, - Self::Chmod => task_tag::Chmod, - Self::Chown => task_tag::Chown, - Self::Close => task_tag::Close, - Self::CopyFile => task_tag::CopyFile, - Self::Exists => task_tag::Exists, - Self::Fchmod => task_tag::Fchmod, - Self::Fchown => task_tag::FChown, - Self::Fdatasync => task_tag::Fdatasync, - Self::Fstat => task_tag::Fstat, - Self::Fsync => task_tag::Fsync, - Self::Ftruncate => task_tag::FTruncate, - Self::Futimes => task_tag::Futimes, - Self::Lchmod => task_tag::Lchmod, - Self::Lchown => task_tag::Lchown, - Self::Link => task_tag::Link, - Self::Lstat => task_tag::Lstat, - Self::Lutimes => task_tag::Lutimes, - Self::Mkdir => task_tag::Mkdir, - Self::Mkdtemp => task_tag::Mkdtemp, - Self::Open => task_tag::Open, - Self::Read => task_tag::Read, - Self::Readdir => task_tag::Readdir, - Self::ReadFile => task_tag::ReadFile, - Self::Readlink => task_tag::Readlink, - Self::Readv => task_tag::Readv, - Self::Realpath => task_tag::Realpath, - Self::RealpathNonNative => task_tag::RealpathNonNative, - Self::Rename => task_tag::Rename, - Self::Rm => task_tag::Rm, - Self::Rmdir => task_tag::Rmdir, - Self::Stat => task_tag::Stat, - Self::Statfs => task_tag::StatFS, - Self::Symlink => task_tag::Symlink, - Self::Truncate => task_tag::Truncate, - Self::Unlink => task_tag::Unlink, - Self::Utimes => task_tag::Utimes, - Self::Write => task_tag::Write, - Self::WriteFile => task_tag::WriteFile, - Self::Writev => task_tag::Writev, + NodeFSFunctionEnum::Open => task_tag::Open, + NodeFSFunctionEnum::Close => task_tag::Close, + NodeFSFunctionEnum::Read => task_tag::Read, + NodeFSFunctionEnum::Write => task_tag::Write, + NodeFSFunctionEnum::Readv => task_tag::Readv, + NodeFSFunctionEnum::Writev => task_tag::Writev, + NodeFSFunctionEnum::Statfs => task_tag::StatFS, + _ => panic!("not a libuv-request fs op"), } } } diff --git a/src/runtime/node/node_fs_stat_watcher.rs b/src/runtime/node/node_fs_stat_watcher.rs index 8898164f74bd..e5c13c53bf5d 100644 --- a/src/runtime/node/node_fs_stat_watcher.rs +++ b/src/runtime/node/node_fs_stat_watcher.rs @@ -59,10 +59,10 @@ pub struct StatWatcherScheduler { is_shutdown: AtomicBool, task: WorkPoolTask, main_thread: ThreadId, - // JSC_BORROW per LIFETIMES.tsv — VM outlives the scheduler. `BackRef` gives - // safe `&VirtualMachine` projection (Deref) at every read site; - // `event_loop_shared()` / `enqueue_task_concurrent` take `&self`. + /// JS-thread uses only (`timer_callback`). vm: BackRef, + /// How the pool thread asks the JS thread to (re)arm the timer. + loop_handle: bun_jsc::LoopHandle, watchers: WatcherQueue, pub(crate) event_loop_timer: EventLoopTimer, @@ -187,6 +187,8 @@ impl StatWatcherScheduler { main_thread: thread::current().id(), // JSC_BORROW: `vm` is the live per-thread VM (never null). vm: BackRef::from(core::ptr::NonNull::new(vm).expect("vm")), + // SAFETY: `vm` is the live per-thread VM; this runs on its thread. + loop_handle: unsafe { (*vm).loop_handle() }, watchers: WatcherQueue::default(), event_loop_timer: EventLoopTimer::init_paused(EventLoopTimerTag::StatWatcherScheduler), ref_count: ThreadSafeRefCount::init(), @@ -292,12 +294,17 @@ impl StatWatcherScheduler { // `set_timer`), kept alive across the hop by the watcher's RefPtr. scheduler: unsafe { ParentRef::from_raw_mut(this) }, }); - // SAFETY: `vm` is the live per-thread VM (JSC_BORROW). + // SAFETY: `this` is live (kept by the watcher's RefPtr across the hop). unsafe { - (*this) - .vm - .event_loop_shared() - .enqueue_task_concurrent(ConcurrentTask::create(Task::from_boxed(holder))); + let holder = bun_core::heap::into_raw(holder); + let ct = ConcurrentTask::create(Task::new( + ::TAG, + holder.cast::<()>(), + )); + // Posted from the counted pool task: the VM has not closed its handle. + let bun_jsc::vm_handle::Posted::Queued = (*this).loop_handle.post_task(ct) else { + unreachable!("VM handle closed with the stat scheduler's pool task outstanding"); + }; } } @@ -343,6 +350,9 @@ impl StatWatcherScheduler { // of accumulating one leak per `set_interval(0)` / re-arm. // SAFETY: `self` is live (`&mut self`). Self::ref_(core::ptr::from_mut(self)); + // The task is a field of this per-VM scheduler: counted, so the VM + // waits for it (see `VmHandle::embedded_work_scheduled`). + self.loop_handle.embedded_work_scheduled(); WorkPool::schedule(&raw mut self.task); } @@ -421,6 +431,9 @@ impl StatWatcherScheduler { // Publish the queue writes above before declaring the work-pool hop // finished; `shutdown_for_exit` Acquire-loads this and then drains. this_ref.work_pool_in_flight.store(false, Ordering::Release); + let handle = this_ref.loop_handle.clone(); + drop(_ref_guard); + handle.embedded_work_finished(); } /// Drain every queued [`StatWatcher`] and release the per-VM scheduler ref @@ -501,8 +514,12 @@ impl StatWatcherScheduler { pub struct StatWatcher { pub(crate) next: bun_threading::Link, // INTRUSIVE link for UnboundedQueue - // JSC_BORROW per LIFETIMES.tsv — VM outlives the watcher. + /// JS-thread uses only. ctx: BackRef, + /// How the pool thread delivers stat results to the VM. + /// The pending pool→JS hop, if any (one at a time: the initial stat, then restats). + pending_hop: Cell, + loop_handle: bun_jsc::LoopHandle, ref_count: ThreadSafeRefCount, @@ -656,14 +673,41 @@ impl StatWatcher { std::ptr::from_ref::(self).cast_mut() } + /// Pool thread → JS thread: `hop` runs there and consumes one ref on + /// `self`. Posted from counted (embedded) pool work, so always queued; a + /// VM tearing down releases the ref from its queue instead of running it. + fn post_to_js_thread(&self, hop: StatWatcherHop) { + self.pending_hop.set(hop as u8); + let task = ConcurrentTask::create(Task::init(self.as_ctx_ptr())); + let bun_jsc::vm_handle::Posted::Queued = self.loop_handle.post_task(task) else { + unreachable!("VM handle closed with stat-watcher pool work outstanding"); + }; + } + + /// JS thread dispatch of a [`post_to_js_thread`](Self::post_to_js_thread) hop. + /// /// # Safety - /// `task` must be a fresh heap-allocated `ConcurrentTask` not yet enqueued - /// elsewhere; the queue takes ownership of it. - fn enqueue_task_concurrent( - &self, - task: NonNull, - ) { - self.ctx.event_loop_shared().enqueue_task_concurrent(task); + /// `this` is the watcher the pool posted (ref held for the hop). + pub(crate) unsafe fn run_hop(this: *mut StatWatcher) -> bun_event_loop::JsResult<()> { + // SAFETY: fn contract. + let hop = unsafe { (*this).pending_hop.get() }; + match hop { + x if x == StatWatcherHop::InitialStatSuccess as u8 => { + Self::initial_stat_success_on_main_thread(this) + } + x if x == StatWatcherHop::InitialStatError as u8 => { + Self::initial_stat_error_on_main_thread(this) + } + _ => Self::swap_and_call_listener_on_main_thread(this), + } + } + + /// VM teardown release of a queued hop (JS thread): drop the hop's ref. + /// + /// # Safety + /// As [`run_hop`](Self::run_hop). + pub(crate) unsafe fn release_hop(this: *mut StatWatcher) { + Self::deref(this); } /// Copy the last stat by value. @@ -700,7 +744,7 @@ impl StatWatcher { // Isolation-registry removal lives in `close()`, NOT here: the last // `deref` can happen on the work-pool thread (queue ref dropped in // `work_pool_callback` / `InitialStatTask`), where the thread-local - // `isolation_handles()` is null and the removal would silently no-op, + // `active_handles()` is null and the removal would silently no-op, // leaving a dangling registry pointer. Every deinit of a registered // watcher is preceded by a JS-thread `close()` (the Strong `this_value` // self-ref keeps the wrapper alive until `close()` downgrades it, so @@ -755,18 +799,16 @@ impl StatWatcher { /// Stops file watching but does not free the instance. /// - /// Always runs on the JS thread (`do_close`, `close_isolation_handles`, + /// Always runs on the JS thread (`do_close`, `stop_active_handles_for_vm_teardown`, /// `shutdown_for_exit`), so this is where the watcher leaves the /// isolation registry — `deinit` can fire on the work-pool thread where /// the thread-local registry is unreachable. pub(crate) fn close(&self) { // `ctx` is a `BackRef` (JSC_BORROW); safe Deref. - if self.ctx.test_isolation_enabled { - if let Some(handles) = crate::jsc_hooks::isolation_handles() { - handles.swap_remove(&crate::jsc_hooks::IsolationHandle::StatWatcher( - NonNull::from(self), - )); - } + if let Some(handles) = crate::jsc_hooks::active_handles() { + handles.swap_remove(&crate::jsc_hooks::ActiveHandle::StatWatcher(NonNull::from( + self, + ))); } if self.persistent.get() { self.persistent.set(false); @@ -919,10 +961,7 @@ impl StatWatcher { // shared (`&*const`), so no write provenance is required. let this_ptr: *mut StatWatcher = self.as_ctx_ptr(); Self::ref_(this_ptr); - self.enqueue_task_concurrent(ConcurrentTask::from_callback( - this_ptr, - Self::swap_and_call_listener_on_main_thread, - )); + self.post_to_js_thread(StatWatcherHop::Changed); } /// After a restat found the file changed, this calls the listener function. @@ -995,6 +1034,9 @@ impl StatWatcher { // for the `rare_data()` call in `deinit`. // SAFETY: `bun_vm_ptr()` is the live per-thread VM, non-null, outlives the watcher. ctx: unsafe { BackRef::from_raw_mut(vm) }, + pending_hop: Cell::new(0), + // SAFETY: `vm` is the live per-thread VM; this runs on its thread. + loop_handle: unsafe { (*vm).loop_handle() }, ref_count: ThreadSafeRefCount::init(), closed: AtomicBool::new(false), path: alloc_file_path, @@ -1036,15 +1078,13 @@ impl StatWatcher { .set(JsRef::init_strong(js_this, &args.global_this)); js::listener_set_cached(js_this, &args.global_this, args.listener); // `ctx` is a `BackRef` (JSC_BORROW); safe Deref. - if this_ref.ctx.test_isolation_enabled { - if let Some(handles) = crate::jsc_hooks::isolation_handles() { - bun_core::handle_oom(handles.put( - crate::jsc_hooks::IsolationHandle::StatWatcher( - NonNull::new(this_ptr).expect("init: watcher"), - ), - (), - )); - } + if let Some(handles) = crate::jsc_hooks::active_handles() { + bun_core::handle_oom(handles.put( + crate::jsc_hooks::ActiveHandle::StatWatcher( + NonNull::new(this_ptr).expect("init: watcher"), + ), + (), + )); } // SAFETY: `this_ptr` was just leaked from `Box`; live with refcount 1. InitialStatTask::create_and_schedule(this_ptr); @@ -1144,6 +1184,24 @@ impl Arguments { } } +/// Which JS-thread continuation a posted [`StatWatcher`] hop runs. +#[repr(u8)] +#[derive(Clone, Copy)] +pub(crate) enum StatWatcherHop { + InitialStatSuccess = 1, + InitialStatError = 2, + Changed = 3, +} + +impl bun_event_loop::Taskable for StatWatcher { + const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::StatWatcherHop; + /// A continuation the pool posted: drop the ref it carries. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract. + unsafe { StatWatcher::release_hop(this) } + } +} + pub(crate) struct InitialStatTask { // StatWatcher is intrusively ref-counted (ThreadSafeRefCount m_ctx // payload). We hold the strong ref via `ref_()`/`deref()` and keep the @@ -1162,6 +1220,10 @@ impl InitialStatTask { // the task lifetime (balanced by `deref()` in run_owned's closed path or // by the main-thread `initial_stat_*_on_main_thread` callbacks). StatWatcher::ref_(watcher); + // The watcher is a JS-owned m_ctx: counted, so its VM waits for this + // (see `VmHandle::embedded_work_scheduled`). + // SAFETY: per fn contract. + unsafe { (*watcher).loop_handle.embedded_work_scheduled() }; WorkPool::schedule_new(InitialStatTask { watcher, task: WorkPoolTask::default(), @@ -1184,6 +1246,8 @@ impl InitialStatTask { // both also deref as shared (R-2), so aliased `&` is sound. // `ParentRef` Deref gives that shared `&`. let this_ref = ParentRef::from(NonNull::new(this).expect("run_owned: watcher")); + let handle = this_ref.loop_handle.clone(); + let _finished = scopeguard::guard((), |()| handle.embedded_work_finished()); if this_ref.closed.load(Ordering::Relaxed) { // Balance the ref() from createAndSchedule(). @@ -1197,20 +1261,14 @@ impl InitialStatTask { Ok(ref res) => { // we store the stat, but do not call the callback this_ref.set_last_stat(res); - this_ref.enqueue_task_concurrent(ConcurrentTask::from_callback( - this, - StatWatcher::initial_stat_success_on_main_thread, - )); + this_ref.post_to_js_thread(StatWatcherHop::InitialStatSuccess); } Err(_) => { // on enoent, eperm, we call cb with two zeroed stat objects // and store previous stat as a zeroed stat object, and then call the callback. // SAFETY: all-zero is a valid PosixStat (POD #[repr(C)]) this_ref.set_last_stat(&bun_core::ffi::zeroed::()); - this_ref.enqueue_task_concurrent(ConcurrentTask::from_callback( - this, - StatWatcher::initial_stat_error_on_main_thread, - )); + this_ref.post_to_js_thread(StatWatcherHop::InitialStatError); } } // ref ownership transferred to main-thread callback @@ -1236,4 +1294,10 @@ impl StatWatcherTimerUpdate { impl bun_event_loop::Taskable for StatWatcherTimerUpdate { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::StatWatcherTimerUpdate; + /// The holder owns nothing (a non-owning scheduler ref); timers are + /// already disarmed, so just drop it. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — the box `schedule_timer_update` posted. + drop(unsafe { bun_core::heap::take(this) }); + } } diff --git a/src/runtime/node/node_fs_watcher.rs b/src/runtime/node/node_fs_watcher.rs index 4c6423a47270..51fdf3acf30c 100644 --- a/src/runtime/node/node_fs_watcher.rs +++ b/src/runtime/node/node_fs_watcher.rs @@ -44,7 +44,12 @@ use super::win_watcher as path_watcher; #[bun_jsc::JsClass(no_constructor)] pub struct FSWatcher { // codegen: jsc.Codegen.JSFSWatcher provides toJS/fromJS/fromJSDirect + /// JS-thread uses only. ctx: *mut VirtualMachine, + /// How the watcher thread delivers event batches to the VM (POSIX; on + /// Windows libuv delivers fs events on the JS thread). + #[cfg(not(windows))] + loop_handle: bun_jsc::LoopHandle, verbose: bool, mutex: Mutex, @@ -75,11 +80,12 @@ pub mod js { } impl FSWatcher { + /// JS thread only (Windows delivers fs events on the loop thread). + #[cfg(windows)] #[inline] - fn vm(&self) -> &'static mut VirtualMachine { - // SAFETY: BACKREF — `ctx` is the per-thread `VirtualMachine` singleton - // (set in `init` from `globalThis.bunVM()`); it outlives every - // FSWatcher and all access is on the JS thread. + fn vm(&self) -> &mut VirtualMachine { + // SAFETY: `ctx` is the live per-thread VM (set in `init`); every caller + // is on its JS thread. unsafe { &mut *self.ctx } } @@ -89,16 +95,15 @@ impl FSWatcher { unsafe { VirtualMachine::event_loop_ctx(self.ctx) } } - /// `task` must point to a live heap-allocated `ConcurrentTask` node that - /// the caller releases ownership of; the concurrent queue takes ownership - /// and frees it on the JS thread after dispatch. + /// Watcher thread → JS thread. `task` is the intrusive node of a heap batch + /// task; the queue takes ownership unless the VM has been torn down, in + /// which case the caller gets it back. #[cfg(not(windows))] - pub(crate) fn enqueue_task_concurrent(&self, task: core::ptr::NonNull) { - // `vm()` is the BACKREF accessor; `event_loop_shared()` is the audited - // safe `&EventLoop` accessor. `enqueue_task_concurrent` is the - // documented cross-thread entry point and only touches the lock-free - // queue. - self.vm().event_loop_shared().enqueue_task_concurrent(task); + pub(crate) fn post( + &self, + task: core::ptr::NonNull, + ) -> bun_jsc::vm_handle::Posted { + self.loop_handle.post_task(task) } /// `self`'s address as `*mut Self` for path-watcher / abort-signal / @@ -142,6 +147,17 @@ pub struct FSWatchTaskPosix { #[cfg(not(windows))] impl Taskable for FSWatchTaskPosix { const TAG: TaskTag = task_tag::FSWatchTask; + /// A batch of events the watcher thread posted that nobody will emit: + /// free it (its entries own their paths) and drop the activity unit it + /// took — as the refused-post path in `enqueue` does. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract; the FSWatcher outlives its tasks. + unsafe { + let ctx = (*this).ctx; + Self::deinit(this); + ctx.expect("FSWatchTask.ctx unset").get().unref_task(); + } + } } #[cfg(not(windows))] @@ -223,10 +239,16 @@ impl FSWatchTaskPosix { // until the JS thread drains and `heap::take`s it in `dispatch`. unsafe { (*that).concurrent_task.task = Task::init(that); - self.ctx() - .enqueue_task_concurrent(core::ptr::NonNull::new_unchecked( - core::ptr::addr_of_mut!((*that).concurrent_task), - )); + let node = core::ptr::NonNull::new_unchecked(core::ptr::addr_of_mut!( + (*that).concurrent_task + )); + if let bun_jsc::vm_handle::Posted::Refused(_) = self.ctx().post(node) { + // VM torn down: nobody will emit these events. Free the batch + // (its entries own their paths) and drop the activity ref. + let mut task = bun_core::heap::take(that); + task.clean_entries(); + self.ctx().unref_task(); + } } return; } @@ -341,6 +363,15 @@ pub struct FSWatchTaskWindows { #[cfg(windows)] impl Taskable for FSWatchTaskWindows { const TAG: TaskTag = task_tag::FSWatchTask; + /// As the POSIX task: free the batch, drop the activity unit. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract; the FSWatcher outlives its tasks. + unsafe { + let ctx = (*this).ctx; + Self::deinit(this); + ctx.expect("FSWatchTask.ctx unset").get().unref_task(); + } + } } #[cfg(windows)] @@ -1024,12 +1055,10 @@ impl FSWatcher { // this can be called multiple times pub(crate) fn detach(&self) { let ctx_ptr = self.as_ctx_ptr().cast::(); - if self.vm().test_isolation_enabled { - if let Some(handles) = crate::jsc_hooks::isolation_handles() { - handles.swap_remove(&crate::jsc_hooks::IsolationHandle::FsWatcher( - core::ptr::NonNull::from(self), - )); - } + if let Some(handles) = crate::jsc_hooks::active_handles() { + handles.swap_remove(&crate::jsc_hooks::ActiveHandle::FsWatcher( + core::ptr::NonNull::from(self), + )); } if let Some(watcher) = self.path_watcher.take() { @@ -1102,6 +1131,8 @@ impl FSWatcher { let ctx = bun_core::heap::into_raw(Box::new(FSWatcher { ctx: vm, + #[cfg(not(windows))] + loop_handle: vm_ref.loop_handle(), current_task: JsCell::new(FSWatchTask { ctx: None, ..Default::default() @@ -1173,15 +1204,13 @@ impl FSWatcher { args.listener.with_async_context_if_needed(args.global_this), ) }; - if vm_ref.test_isolation_enabled { - if let Some(handles) = crate::jsc_hooks::isolation_handles() { - bun_core::handle_oom(handles.put( - crate::jsc_hooks::IsolationHandle::FsWatcher( - core::ptr::NonNull::new(ctx).expect("init: watcher"), - ), - (), - )); - } + if let Some(handles) = crate::jsc_hooks::active_handles() { + bun_core::handle_oom(handles.put( + crate::jsc_hooks::ActiveHandle::FsWatcher( + core::ptr::NonNull::new(ctx).expect("init: watcher"), + ), + (), + )); } Ok(ctx) } diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index 437f4b06b3b1..d021b241ea51 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -36,6 +36,21 @@ extern "C" fn get_exec_path(global_object: &JSGlobalObject) -> JSValue { ZigString::from_utf8(out.as_bytes()).to_js(global_object) } +/// A worker's `argv`/`execArgv` strings live in its parent-thread +/// `WorkerOptions`; the worker thread gets its own copy (thread-affine +/// refcounts), and an empty one is spelled as `BunString::empty()`. +pub(crate) fn worker_option_string(wtf: bun_core::WTFStringImpl) -> bun_core::OwnedString { + // SAFETY: non-null impl borrowed from the live `WorkerOptions`. + let imp = unsafe { &*wtf }; + bun_core::OwnedString::new(if imp.length() == 0 { + bun_core::String::empty() + } else if imp.is_8bit() { + bun_core::String::clone_latin1(imp.latin1_slice()) + } else { + bun_core::String::clone_utf16(imp.utf16_slice()) + }) +} + // ───────────────────────────── argv (C++ accessor wrappers) ───────────────── pub(crate) extern "C" fn get_argv(global: &JSGlobalObject) -> JSValue { @@ -247,7 +262,7 @@ mod _impl { // was explicitly overridden for the worker? if let Some(exec_argv) = worker.exec_argv() { return JSValue::create_array_from_iter(global_object, exec_argv.iter(), |&wtf| { - BunString::init(wtf).to_js(global_object) + super::worker_option_string(wtf).to_js(global_object) }); } } @@ -420,10 +435,14 @@ mod _impl { } } + let mut worker_args: Vec = Vec::new(); if let Some(worker) = worker { - for &arg in worker.argv() { - args_list.push(BunString::init(arg)); - } + worker_args = worker + .argv() + .iter() + .map(|&arg| super::worker_option_string(arg)) + .collect(); + args_list.extend(worker_args.iter().map(|s| **s)); } else { for arg in &vm.argv { let str_ = BunString::borrow_utf8(arg); @@ -432,7 +451,9 @@ mod _impl { } } - bun_string_jsc::to_js_array(global_object, &args_list).unwrap_or(JSValue::ZERO) + let array = bun_string_jsc::to_js_array(global_object, &args_list); + drop(worker_args); + bun_jsc::HostReturn::or_pending_exception(array) } // ───────────────────────────── eval ───────────────────────────── diff --git a/src/runtime/node/node_zlib_binding.rs b/src/runtime/node/node_zlib_binding.rs index e397105e942d..787f89b44f55 100644 --- a/src/runtime/node/node_zlib_binding.rs +++ b/src/runtime/node/node_zlib_binding.rs @@ -203,6 +203,8 @@ pub(crate) trait CompressionStreamImpl: Sized + Taskable + 'static { /// Implementations store a `BackRef`; the single unsafe /// deref lives in `BackRef::get`, so callers and impls are safe. fn global_this(&self) -> &JSGlobalObject; + /// How the pool thread reaches the VM (captured at construction). + fn loop_handle(&self) -> &bun_jsc::LoopHandle; fn stream(&self) -> &JsCell; /// Write `(avail_out, avail_in)` into the JS-owned 2-element `Uint32Array` @@ -464,6 +466,9 @@ impl CompressionStream { callback: Self::async_job_run_task, }); this.poll_ref().with_mut(|p| p.ref_(vm)); + // The task is a field of this JS-owned stream: counted, so the VM waits + // for it (see `VmHandle::embedded_work_scheduled`). + this.loop_handle().embedded_work_scheduled(); WorkPool::schedule(this.task().as_ptr()); Ok(JSValue::UNDEFINED) @@ -487,25 +492,51 @@ impl CompressionStream { // `ref_()` in `write()`); bodies use the `&self` accessor surface // (R-2). `ParentRef` Deref collapses the per-site raw deref. let this_ref = ParentRef::from(NonNull::new(this).expect("async_job_run: this")); - let global_this: &JSGlobalObject = this_ref.global_this(); - // `bun_vm_concurrently()` is the thread-safe accessor (skips the - // JS-thread debug assert; same backing pointer as `bun_vm()`). - // BACKREF — `bun_vm_concurrently()` never returns null for a Bun-owned - // global; wrap once so the `event_loop()` read below is safe Deref. - let vm = ParentRef::from( - NonNull::new(global_this.bun_vm_concurrently()).expect("bun_vm_concurrently"), - ); - this_ref.stream().with_mut(|s| s.do_work()); + // The stream reads and writes JS ArrayBuffer backing stores: only while + // the VM is running, under a borrow. Either way the completion goes + // back to the JS thread, which finishes or releases the write there — + // the VM waits for this (embedded work) before its handle closes. + let loop_handle = this_ref.loop_handle().clone(); + if let Some(_vm) = loop_handle.borrow_if_running() { + this_ref.stream().with_mut(|s| s.do_work()); + } + let ct = ConcurrentTask::create(Task::init(this)); + let bun_jsc::vm_handle::Posted::Queued = loop_handle.post_task(ct) else { + unreachable!("VM handle closed with an embedded zlib write outstanding"); + }; + // `this` may already be freed by the JS thread; the handle is ours. + loop_handle.embedded_work_finished(); + } - // SAFETY: `event_loop()` is a self-pointer into a live VM; the - // `enqueue_task_concurrent` body only touches the lock-free - // `concurrent_tasks` queue (thread-safe). `this` is the heap-allocated - // `m_ctx` payload — the matching `ref()` in `write()` keeps it alive - // until `run_from_js_thread` runs and calls `deref()`. - unsafe { - (*vm.event_loop()).enqueue_task_concurrent(ConcurrentTask::create(Task::init(this))); + /// VM teardown, JS thread, heap alive: a completion that was queued but + /// will not run. The cleanup half of `run_from_js_thread`, no callbacks. + /// + /// # Safety + /// As [`run_from_js_thread`](Self::run_from_js_thread). + pub(crate) unsafe fn release_unrun(this_ptr: *mut T) { + let this = ParentRef::from(NonNull::new(this_ptr).expect("release_unrun: this")); + let global: &JSGlobalObject = this.global_this(); + let vm = global.bun_vm(); + this.write_in_progress().set(false); + if let Some(this_value) = this.this_value().with_mut(|v| v.try_swap()) { + for pinned in [ + T::pending_input_get_cached(this_value), + T::pending_output_get_cached(this_value), + ] + .into_iter() + .flatten() + { + if pinned.is_cell() { + if let Some(buf) = pinned.as_array_buffer(global) { + buf.unpin(); + } + } + } } + this.poll_ref().with_mut(|p| p.unref(vm)); + // SAFETY: fn contract — the write's ref. + unsafe { T::deref(this_ptr) }; } /// Dispatched from `dispatch.rs` when the worker-thread `do_work()` posts @@ -987,9 +1018,14 @@ pub(crate) fn native_zstd(global: &JSGlobalObject) -> JSValue { #[doc(hidden)] macro_rules! __impl_compression_stream { ($native:ident, $ctx:ty, $type_name:literal) => { - // Tag for the event-loop dispatcher (bun_runtime::dispatch::run_task). impl ::bun_event_loop::Taskable for $native { const TAG: ::bun_event_loop::TaskTag = ::bun_event_loop::task_tag::$native; + /// An async write whose completion will not run: unpin, unref, drop + /// the write's ref — no callbacks. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — the stream the pool posted (write's ref held). + unsafe { $crate::node::node_zlib_binding::CompressionStream::<$native>::release_unrun(this) } + } } /// `T.js.*` — cached-property accessors emitted by @@ -1014,6 +1050,7 @@ macro_rules! __impl_compression_stream { type Stream = $ctx; #[inline] fn global_this(&self) -> &::bun_jsc::JSGlobalObject { self.global_this.get() } + #[inline] fn loop_handle(&self) -> &::bun_jsc::LoopHandle { &self.loop_handle } #[inline] fn stream(&self) -> &::bun_jsc::JsCell { &self.stream } #[inline] fn poll_ref(&self) -> &::bun_jsc::JsCell<$crate::node::node_zlib_binding::CountedKeepAlive> { &self.poll_ref } #[inline] fn this_value(&self) -> &::bun_jsc::JsCell<::bun_jsc::StrongOptional> { &self.this_value } diff --git a/src/runtime/node/types.rs b/src/runtime/node/types.rs index 995e5ca1df13..12f682ee117f 100644 --- a/src/runtime/node/types.rs +++ b/src/runtime/node/types.rs @@ -356,7 +356,7 @@ impl StringOrBuffer { if buffer.buffer.value != JSValue::ZERO { return Ok(buffer.buffer.value); } - Ok(buffer.to_node_buffer(ctx)) + buffer.to_node_buffer(ctx) } } } diff --git a/src/runtime/node/zlib/NativeBrotli.rs b/src/runtime/node/zlib/NativeBrotli.rs index e2771bed1460..d43461514804 100644 --- a/src/runtime/node/zlib/NativeBrotli.rs +++ b/src/runtime/node/zlib/NativeBrotli.rs @@ -85,6 +85,8 @@ mod _impl { // JSC_BORROW backref; global outlives this m_ctx payload. `BackRef` // centralises the single unsafe deref so the trait impl is safe. pub global_this: bun_ptr::BackRef, + /// How the pool thread delivers a finished write to the VM. + pub loop_handle: bun_jsc::LoopHandle, pub stream: JsCell, pub poll_ref: JsCell, // TODO: Strong self-ref on the wrapper → JsRef per PORTING.md §JSC (Strong back-ref to own wrapper leaks) @@ -146,8 +148,8 @@ mod _impl { }; Ok(Box::new(Self { ref_count: Cell::new(1), - // JSC_BORROW backref — the global outlives this m_ctx payload. global_this: bun_ptr::BackRef::new(global_this), + loop_handle: global_this.bun_vm().loop_handle(), stream: JsCell::new(stream), poll_ref: JsCell::new(CountedKeepAlive::default()), this_value: JsCell::new(StrongOptional::empty()), diff --git a/src/runtime/node/zlib/NativeZlib.rs b/src/runtime/node/zlib/NativeZlib.rs index 309ebefcc1fa..1945620f83b9 100644 --- a/src/runtime/node/zlib/NativeZlib.rs +++ b/src/runtime/node/zlib/NativeZlib.rs @@ -42,6 +42,8 @@ mod _impl { // JSC_BORROW backref; global outlives this m_ctx payload. `BackRef` // centralises the single unsafe deref so the trait impl is safe. pub global_this: bun_ptr::BackRef, + /// How the pool thread delivers a finished write to the VM. + pub loop_handle: bun_jsc::LoopHandle, pub stream: JsCell, pub poll_ref: JsCell, pub this_value: JsCell, // jsc.Strong.Optional @@ -94,8 +96,8 @@ mod _impl { }; Ok(Box::new(Self { ref_count: Cell::new(1), - // JSC_BORROW backref — the global outlives this m_ctx payload. global_this: bun_ptr::BackRef::new(global), + loop_handle: global.bun_vm().loop_handle(), stream: JsCell::new(stream), poll_ref: JsCell::new(CountedKeepAlive::default()), this_value: JsCell::new(StrongOptional::empty()), diff --git a/src/runtime/node/zlib/NativeZstd.rs b/src/runtime/node/zlib/NativeZstd.rs index e6ac2984b7a8..b773775b847b 100644 --- a/src/runtime/node/zlib/NativeZstd.rs +++ b/src/runtime/node/zlib/NativeZstd.rs @@ -41,6 +41,8 @@ mod _impl { // LIFETIMES.tsv: JSC_BORROW. The global outlives this m_ctx payload; // `BackRef` centralises the single unsafe deref so the trait impl is safe. pub global_this: bun_ptr::BackRef, + /// How the pool thread delivers a finished write to the VM. + pub loop_handle: bun_jsc::LoopHandle, pub stream: JsCell, pub poll_ref: JsCell, pub this_value: JsCell, // jsc.Strong.Optional @@ -104,10 +106,11 @@ mod _impl { ..Default::default() }; Ok(Box::new(Self { - ref_count: Cell::new(1), // RefCount.init() + ref_count: Cell::new(1), // JSC_BORROW — the JSGlobalObject outlives this payload (the C++ // wrapper is owned by that global's heap). global_this: bun_ptr::BackRef::new(global), + loop_handle: global.bun_vm().loop_handle(), stream: JsCell::new(stream), poll_ref: JsCell::new(CountedKeepAlive::default()), this_value: JsCell::new(StrongOptional::empty()), diff --git a/src/runtime/server/FileResponseStream.rs b/src/runtime/server/FileResponseStream.rs index 2778b660b0db..af51dd28e600 100644 --- a/src/runtime/server/FileResponseStream.rs +++ b/src/runtime/server/FileResponseStream.rs @@ -661,4 +661,9 @@ fn can_sendfile(resp: AnyResponse, file_type: FileType, length: Option) -> impl bun_event_loop::Taskable for FileResponseStream { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::FileResponseStreamEof; + /// `on_read_chunk` took a ref for the queued EOF hop; drop it. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract; adopts the ref the enqueue took. + drop(unsafe { bun_ptr::ScopedRef::::adopt(this) }); + } } diff --git a/src/runtime/server/HTMLBundle.rs b/src/runtime/server/HTMLBundle.rs index 62428735c64f..98b5db8e8893 100644 --- a/src/runtime/server/HTMLBundle.rs +++ b/src/runtime/server/HTMLBundle.rs @@ -192,7 +192,8 @@ impl State { // SAFETY: `c` was produced by `create_and_schedule_completion_task` // (heap::alloc, refcount ≥ 1) and we hold one of those refs. unsafe { - (*c).cancelled = true; + (*c).cancelled + .store(true, core::sync::atomic::Ordering::Release); RefCount::::deref(c); } } @@ -498,8 +499,7 @@ impl Route { } config.source_map = bundler_options::SourceMapOption::Linked; - let completion_task = - create_and_schedule_completion_task(config, plugins, global, vm.event_loop())?; + let completion_task = create_and_schedule_completion_task(config, plugins, global)?; // SAFETY: `completion_task` is the freshly-boxed allocation (refcount==1); sole owner. unsafe { (*completion_task).started_at_ns = diff --git a/src/runtime/server/NodeHTTPResponse.rs b/src/runtime/server/NodeHTTPResponse.rs index 674bccdcb0c1..f8c356e2901c 100644 --- a/src/runtime/server/NodeHTTPResponse.rs +++ b/src/runtime/server/NodeHTTPResponse.rs @@ -1418,14 +1418,14 @@ impl NodeHTTPResponse { _frame: &CallFrame, ) -> JsResult { Ok(self - .drain_buffered_request_body_from_pause(global_object) + .drain_buffered_request_body_from_pause(global_object)? .unwrap_or(JSValue::UNDEFINED)) } fn drain_buffered_request_body_from_pause( &self, global_object: &JSGlobalObject, - ) -> Option { + ) -> JsResult> { scoped_log!( NodeHTTPResponse, "drainBufferedRequestBodyFromPause {}", @@ -1440,15 +1440,17 @@ impl NodeHTTPResponse { let bytes = self .buffered_request_body_data_during_pause .replace(Vec::new()); - return Some(JSValue::create_buffer_from_box( - global_object, - bytes.into_boxed_slice(), - )); + return JSValue::create_buffer_from_box(global_object, bytes.into_boxed_slice()) + .map(Some); } - None + Ok(None) } - pub(crate) fn do_resume(&self, global_object: &JSGlobalObject, _frame: &CallFrame) -> JSValue { + pub(crate) fn do_resume( + &self, + global_object: &JSGlobalObject, + _frame: &CallFrame, + ) -> JsResult { scoped_log!(NodeHTTPResponse, "doResume"); // Re-arm the poll first, unconditionally: a paused socket that received // the peer's FIN has that EOF deferred (loop.c) until it is resumed, so @@ -1457,7 +1459,7 @@ impl NodeHTTPResponse { self.resume_socket(); let flags = self.flags.get(); let Some(raw) = self.raw_response.get() else { - return JSValue::FALSE; + return Ok(JSValue::FALSE); }; if flags.contains(Flags::REQUEST_HAS_COMPLETED) || flags.contains(Flags::SOCKET_CLOSED) @@ -1467,7 +1469,7 @@ impl NodeHTTPResponse { // here would deliver them twice (and park them in the body buffer). || raw.is_connect_request() { - return JSValue::FALSE; + return Ok(JSValue::FALSE); } // Body already delivered: re-arming onData/onTimeout would overwrite a // pipelined request's userData on the shared HttpResponseData. The drain @@ -1479,12 +1481,9 @@ impl NodeHTTPResponse { raw.on_data(on_data_shim, self.as_ctx_ptr()); } self.update_flags(|f| f.remove(Flags::IS_DATA_BUFFERED_DURING_PAUSE)); - let mut result: JSValue = JSValue::TRUE; - - if let Some(buffered_data) = self.drain_buffered_request_body_from_pause(global_object) { - result = buffered_data; - } - result + Ok(self + .drain_buffered_request_body_from_pause(global_object)? + .unwrap_or(JSValue::TRUE)) } pub(crate) fn on_request_complete(&self) { @@ -1673,21 +1672,22 @@ impl NodeHTTPResponse { }; } - if let Some(buffered_data) = self.drain_buffered_request_body_from_pause(global_this) { - break 'brk buffered_data; - } - - if !chunk.is_empty() { - break 'brk match jsc::ArrayBuffer::create_buffer(global_this, chunk) { - Ok(b) => b, - Err(err) => { - let exc = global_this.take_exception(err); - let _ = bun_vm_mut(global_this).uncaught_exception(global_this, exc, false); - return JSValue::UNDEFINED; - } - }; - } - break 'brk JSValue::UNDEFINED; + let created = match self.drain_buffered_request_body_from_pause(global_this) { + Ok(Some(buffered_data)) => Ok(buffered_data), + Ok(None) if !chunk.is_empty() => { + jsc::ArrayBuffer::create_buffer(global_this, chunk) + } + Ok(None) => Ok(JSValue::UNDEFINED), + Err(err) => Err(err), + }; + break 'brk match created { + Ok(b) => b, + Err(err) => { + let exc = global_this.take_exception(err); + let _ = bun_vm_mut(global_this).uncaught_exception(global_this, exc, false); + return JSValue::UNDEFINED; + } + }; }; bytes } diff --git a/src/runtime/server/ServerWebSocket.rs b/src/runtime/server/ServerWebSocket.rs index 96c5aa4e8e33..8c01305a3f4c 100644 --- a/src/runtime/server/ServerWebSocket.rs +++ b/src/runtime/server/ServerWebSocket.rs @@ -420,11 +420,6 @@ impl ServerWebSocket { let global_object = handler.global_object(); let on_open_handler = handler.on_open; let on_error = handler.on_error; - if vm.is_shutting_down() { - bun_output::scoped_log!(WebSocketServer, "onOpen called after script execution"); - ws.close(); - return; - } self.update_flags(|f| f.set_opened(false)); @@ -493,27 +488,26 @@ impl ServerWebSocket { let global_object = self.handler().global_object(); // This is the start of a task. let vm = self.handler().vm(); - if vm.is_shutting_down() { - bun_output::scoped_log!(WebSocketServer, "onMessage called after script execution"); - ws.close(); - return; - } let _loop_guard = vm.enter_event_loop_scope(); + let data = match opcode { + Opcode::Text => jsc::bun_string_jsc::create_utf8_for_js(global_object, message), + Opcode::Binary => self.binary_to_js(global_object, message), + _ => unreachable!(), + }; + let data = match data { + Ok(v) => v, + // Converting the payload threw (or the VM is terminating): there is + // no message to deliver; the exception is reported, not passed on. + Err(e) => return global_object.report_active_exception_as_unhandled(e), + }; let arguments = [ self.this_value .get() .try_get() .unwrap_or(JSValue::UNDEFINED), - match opcode { - Opcode::Text => jsc::bun_string_jsc::create_utf8_for_js(global_object, message) - .unwrap_or(JSValue::ZERO), // TODO: properly propagate exception upwards - Opcode::Binary => self - .binary_to_js(global_object, message) - .unwrap_or(JSValue::ZERO), // TODO: properly propagate exception upwards - _ => unreachable!(), - }, + data, ]; let mut corker = Corker { @@ -562,7 +556,7 @@ impl ServerWebSocket { bun_output::scoped_log!(WebSocketServer, "onDrain"); let handler = self.handler(); let vm = handler.vm(); - if self.is_closed() || vm.is_shutting_down() { + if self.is_closed() { return; } @@ -610,7 +604,7 @@ impl ServerWebSocket { let cb = handler.on_ping; let on_error = handler.on_error; let vm = handler.vm(); - if cb.is_empty_or_undefined_or_null() || vm.is_shutting_down() { + if cb.is_empty_or_undefined_or_null() { return; } let global_this = handler.global_object(); @@ -618,13 +612,16 @@ impl ServerWebSocket { // This is the start of a task. let _loop_guard = vm.enter_event_loop_scope(); + let data = match self.binary_to_js(global_this, data) { + Ok(v) => v, + Err(e) => return global_this.report_active_exception_as_unhandled(e), + }; let args = [ self.this_value .get() .try_get() .unwrap_or(JSValue::UNDEFINED), - self.binary_to_js(global_this, data) - .unwrap_or(JSValue::ZERO), // TODO: properly propagate exception upwards + data, ]; if let Err(e) = cb.call(global_this, JSValue::UNDEFINED, &args) { let err = global_this.take_exception(e); @@ -646,20 +643,19 @@ impl ServerWebSocket { let global_this = handler.global_object(); let vm = handler.vm(); - if vm.is_shutting_down() { - return; - } - // This is the start of a task. let _loop_guard = vm.enter_event_loop_scope(); + let data = match self.binary_to_js(global_this, data) { + Ok(v) => v, + Err(e) => return global_this.report_active_exception_as_unhandled(e), + }; let args = [ self.this_value .get() .try_get() .unwrap_or(JSValue::UNDEFINED), - self.binary_to_js(global_this, data) - .unwrap_or(JSValue::ZERO), // TODO: properly propagate exception upwards + data, ]; if let Err(e) = cb.call(global_this, JSValue::UNDEFINED, &args) { let err = global_this.take_exception(e); @@ -723,9 +719,6 @@ impl ServerWebSocket { }); let vm = handler.vm(); - if vm.is_shutting_down() { - return; - } // on_open's error branch closes the socket, landing here with the // termination from its handler still pending. Both branches below diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 75f016942848..564d7dc4c855 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1244,9 +1244,13 @@ impl NewServer { use bun_http_jsc::method_jsc::MethodJsc as _; use node_http_response::Flags as NhrFlags; + // A stopped server, or a VM whose script gate has closed (a worker asked + // to terminate, still draining its loop): uWS requires every dispatched + // request to be answered or adopted, so answer natively. // SAFETY: `this` is the live server backref registered as the uws // userdata; only one borrow derived from it is alive at a time. - if unsafe { &*this }.js_value_for_dispatch().is_none() { + let this_ref = unsafe { &*this }; + if this_ref.js_value_for_dispatch().is_none() || !this_ref.vm().script_allowed() { server_body::respond_stopped_503(resp); return; } @@ -1319,6 +1323,21 @@ impl NewServer { }) .unwrap_or_else(|err| global.take_exception(err)); + if node_http_response.is_null() { + // The request never reached the handler: an exception (in practice + // a termination request landing in the header conversion) unwound + // before the response object existed. Nothing adopted the response; + // answer it natively as above. + if !result.is_empty() && !result.is_termination_exception() { + // SAFETY: `vm` is the process-static VirtualMachine. + let _ = unsafe { (*vm).uncaught_exception(global, result, false) }; + } + server_body::respond_stopped_503(resp); + // SAFETY: same `this`; balances `on_pending_request` above. + unsafe { (*this).on_static_request_complete() }; + return; + } + enum HttpResult { Rejection(JSValue), Exception(JSValue), @@ -1342,6 +1361,14 @@ impl NewServer { needs_to_drain = false; // SAFETY: `vm` is the process-static VirtualMachine. unsafe { (*vm).drain_microtasks() }; + // The drain ran script: an exception it left (a termination + // request landing in it) ends this dispatch like a throw + // from the handler; nothing below may enter script over it. + if global.has_exception() { + break 'brk HttpResult::Exception( + global.take_error(bun_jsc::JsError::Thrown), + ); + } status = promise.status(); } @@ -1615,12 +1642,10 @@ impl NewServer { pub(crate) fn stop_listening(&mut self, abrupt: bool) { // httplog!("stopListening", .{}); - if self.vm().test_isolation_enabled { - if let Some(handles) = crate::jsc_hooks::isolation_handles() { - handles.swap_remove(&crate::jsc_hooks::IsolationHandle::Server(AnyServer::from( - core::ptr::from_ref(self), - ))); - } + if let Some(handles) = crate::jsc_hooks::active_handles() { + handles.swap_remove(&crate::jsc_hooks::ActiveHandle::Server(AnyServer::from( + core::ptr::from_ref(self), + ))); } if Self::HAS_H3 { @@ -2078,12 +2103,10 @@ impl NewServer { // This should've already been handled in stop_listening; however, when // the JS VM terminates, it hypothetically might not call stop_listening. server.notify_inspector_server_stopped(); - if server.vm().test_isolation_enabled { - if let Some(handles) = crate::jsc_hooks::isolation_handles() { - handles.swap_remove(&crate::jsc_hooks::IsolationHandle::Server(AnyServer::from( - this.cast_const(), - ))); - } + if let Some(handles) = crate::jsc_hooks::active_handles() { + handles.swap_remove(&crate::jsc_hooks::ActiveHandle::Server(AnyServer::from( + this.cast_const(), + ))); } if Self::HAS_H3 { @@ -4171,26 +4194,17 @@ pub struct ServerAllConnectionsClosedTask { impl bun_event_loop::Taskable for ServerAllConnectionsClosedTask { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::ServerAllConnectionsClosedTask; + /// A `server.stop()` whose all-closed notification will not run: drop the + /// promise handle with the box. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — the box `schedule` queued. + drop(unsafe { bun_core::heap::take(this) }); + } } impl ServerAllConnectionsClosedTask { - /// Use `ManagedTask::new_owned` (not `Task::init`) so a still-pending task - /// at process exit is freed by `EventLoop::deinit()`. Without this the - /// `Box` (and its `JSPromiseStrong`) leaks 24 bytes per `server.stop()` - /// that races `process.exit()`. `JSPromiseStrong`'s own `Drop` is already - /// a no-op past `is_shutting_down()` (see `bun_jsc::Strong::Impl::destroy`). pub(crate) fn schedule(this: Self, vm: &mut jsc::VirtualMachine) { - fn call_erased(this: *mut ServerAllConnectionsClosedTask) -> bun_event_loop::JsResult<()> { - // `this` is the unique owning pointer heap-allocated below - // in `schedule()`; `ManagedTask::new_owned` invokes this exactly once. - ServerAllConnectionsClosedTask::run_from_js_thread(this, jsc::VirtualMachine::get_mut()) - .map_err(Into::into) - } - let ptr = bun_core::heap::into_raw(Box::new(this)); - vm.enqueue_task(bun_event_loop::ManagedTask::ManagedTask::new_owned( - ptr, - call_erased, - )); + vm.enqueue_task(bun_event_loop::Task::from_boxed(Box::new(this))); } /// Resolve the `server.stop()` promise @@ -4200,10 +4214,7 @@ impl ServerAllConnectionsClosedTask { /// `this` must be the unique owning pointer heap-allocated in /// [`Self::schedule`]; ownership is reclaimed and `this` must not be used /// after this returns. - pub(crate) fn run_from_js_thread( - this: *mut Self, - vm: &mut jsc::VirtualMachine, - ) -> Result<(), jsc::JsTerminated> { + pub(crate) fn run_from_js_thread(this: *mut Self) -> Result<(), jsc::JsTerminated> { httplog!("ServerAllConnectionsClosedTask runFromJSThread"); // SAFETY: `this` was `heap::alloc`'d in `schedule()`; reclaim @@ -4216,10 +4227,8 @@ impl ServerAllConnectionsClosedTask { let global_object: &jsc::JSGlobalObject = bun_opaque::opaque_deref(this.global_object); let _dispatch = this.tracker.dispatch(global_object); - if !vm.is_shutting_down() { - // `JSPromiseStrong`'s Drop runs when `this` falls out of scope. - this.promise.resolve(global_object, JSValue::UNDEFINED)?; - } + // `JSPromiseStrong`'s Drop runs when `this` falls out of scope. + this.promise.resolve(global_object, JSValue::UNDEFINED)?; Ok(()) } } diff --git a/src/runtime/shell/IOReader.rs b/src/runtime/shell/IOReader.rs index 2cf5778088a9..cf9919704e8d 100644 --- a/src/runtime/shell/IOReader.rs +++ b/src/runtime/shell/IOReader.rs @@ -73,22 +73,6 @@ unsafe impl Send for IOReader {} // SAFETY: shell is single-threaded; `Arc` is used purely for refcounting. unsafe impl Sync for IOReader {} -impl IOReader { - /// Drops the last strong ref so the underlying `BufferedReader` - /// closes on the JS thread. - /// - /// # Safety - /// `this` must be the `Arc::as_ptr` of a live `Arc` whose - /// strong count was held by the async-deinit task. - // Forwards `this` to `Arc::decrement_strong_count` without dereferencing; - // not_unsafe_ptr_arg_deref is a false positive on opaque-token forwarding. - #[allow(clippy::not_unsafe_ptr_arg_deref)] - pub(crate) fn deinit_on_main_thread(this: *mut IOReader) { - // SAFETY: precondition above. - unsafe { std::sync::Arc::decrement_strong_count(this) }; - } -} - impl IOReader { #[inline] #[allow(clippy::mut_from_ref)] // interior mutability via UnsafeCell; single-threaded @@ -135,7 +119,7 @@ impl IOReader { } #[cfg(windows)] { - reader.source = Some(bun_io::Source::File(bun_io::Source::open_file(fd))); + reader.set_source(bun_io::Source::File(bun_io::Source::open_file(fd))); } let this = std::sync::Arc::new_cyclic(|w| IOReader { reader: UnsafeCell::new(reader), diff --git a/src/runtime/shell/IOWriter.rs b/src/runtime/shell/IOWriter.rs index c0aede9aa31d..19787dc7e695 100644 --- a/src/runtime/shell/IOWriter.rs +++ b/src/runtime/shell/IOWriter.rs @@ -180,21 +180,6 @@ pub(crate) fn on_poll(writer: &mut Poll, size_hint: isize, hup: bool) { writer.on_poll(size_hint, hup); } -impl IOWriter { - /// Tears down the underlying `WriterImpl` and drops the last strong ref. - /// - /// # Safety - /// `this` must be the `Arc::as_ptr` of a live `Arc` whose strong - /// count is held by the async-deinit task; this call drops that ref. - // Forwards `this` to `Arc::decrement_strong_count` without dereferencing it - // here; not_unsafe_ptr_arg_deref is a false positive on opaque-token forwarding. - #[allow(clippy::not_unsafe_ptr_arg_deref)] - pub(crate) fn deinit_on_main_thread(this: *mut IOWriter) { - // SAFETY: caller contract above. - unsafe { std::sync::Arc::decrement_strong_count(this) }; - } -} - /// Mutable state. Wrapped in `UnsafeCell` so `Arc`-shared callers can /// mutate via `&self` (single-threaded shell). struct State { diff --git a/src/runtime/shell/builtin/cp.rs b/src/runtime/shell/builtin/cp.rs index 20c43807cd72..bb84b01e0054 100644 --- a/src/runtime/shell/builtin/cp.rs +++ b/src/runtime/shell/builtin/cp.rs @@ -483,13 +483,15 @@ impl ShellCpTask { /// [`schedule`](Self::schedule); not touched again on this thread after /// return. pub(crate) unsafe fn cp_on_finish(this: *mut ShellCpTask, result: bun_sys::Maybe<()>) { - // SAFETY: caller contract — `this` is live and exclusively owned by - // this thread until `enqueue_to_event_loop` hands it off. + // SAFETY: caller contract — JS thread, from the `ShellAsyncCpTask`'s + // completion; `this` is live and ours. The pool side finished (and + // stopped counting) when it handed the copy to that task, so continue + // in place rather than bouncing through the concurrent queue again. unsafe { if let Err(e) = result { (*this).err = Some(ShellErr::new_sys(&e)); } - Self::enqueue_to_event_loop(this); + ShellTask::run_from_main_thread::(this); } } @@ -512,6 +514,8 @@ impl ShellCpTask { let st = &raw mut (*this).task; (*st).task.callback = Self::work_pool_callback; (*st).keep_alive.ref_((*st).event_loop.as_event_loop_ctx()); + // Counted until `ShellTask::on_finish` (see `ShellTask::schedule_no_ref`). + (*st).poster.embedded_work_scheduled(); WorkPool::schedule(&raw mut (*st).task); } } @@ -529,9 +533,15 @@ impl ShellCpTask { task, ::TASK_OFFSET, ); + let poster = (*this).task.poster.clone(); if let Some(e) = (*this).run_from_thread_pool_impl() { (*this).err = Some(e); Self::enqueue_to_event_loop(this); + } else { + // The copy now belongs to a `ShellAsyncCpTask` (counted on its + // own, completes on the JS thread via `cp_on_finish`); this + // task's pool part is over. + poster.embedded_work_finished(); } } } @@ -706,36 +716,14 @@ impl ShellCpTask { }, }; - match self.task.event_loop { - EventLoopHandle::Js { .. } => { - let vm_ptr = self - .task - .event_loop - .bun_vm() - .cast::(); - // SAFETY: `Js` arm always has a live VM (set at interpreter - // construction); accessed read-only here for the - // global-object handle and event-loop pointer. - // Read the raw `global` - // field instead of `vm.global()` so the `&mut VirtualMachine` - // passed below doesn't overlap a `&JSGlobalObject` borrow. - let (global, vm) = unsafe { (&*(*vm_ptr).global, &mut *vm_ptr) }; - let _ = crate::node::fs::ShellAsyncCpTask::create_with_shell_task( - global, - args, - vm, - std::ptr::from_mut::(self), - false, - ); - } - EventLoopHandle::Mini(mini) => { - let _ = crate::node::fs::ShellAsyncCpTask::create_mini( - args, - mini.as_ptr(), - std::ptr::from_mut::(self), - ); - } - } + // Pool thread: hand the copy to an fs.cp task bound to the loop and + // poster this shell task captured on its own thread. + let _ = crate::node::fs::ShellAsyncCpTask::create_for_shell( + args, + self.task.event_loop, + self.task.poster.clone(), + std::ptr::from_mut::(self), + ); None } @@ -752,6 +740,15 @@ impl ShellCpTask { impl bun_event_loop::Taskable for ShellCpTask { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::ShellCpTask; + /// A pool completion that will not run: drop the keep-alive and the box + /// (nothing else frees an unrun one). + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — the box the builtin scheduled. + unsafe { + (*this).task.unref_unrun(); + drop(bun_core::heap::take(this)); + } + } } impl crate::shell::interpreter::ShellTaskCtx for ShellCpTask { diff --git a/src/runtime/shell/builtin/ls.rs b/src/runtime/shell/builtin/ls.rs index ba0aabdce452..8f6213a5dcad 100644 --- a/src/runtime/shell/builtin/ls.rs +++ b/src/runtime/shell/builtin/ls.rs @@ -397,15 +397,25 @@ impl ShellLsTask { /// discovered subdirectory. fn enqueue(&mut self, name: &[u8]) { let new_path = self.join(name); - let subtask = ShellLsTask::create( - self.cmd, - self.opts, - self.task_count, - self.cwd, - new_path, - self.event_loop, - self.interp, - ); + // Pool thread: the subtask inherits our poster rather than deriving + // one from the VM (`ShellTask::new` is JS-thread only). + let subtask = bun_core::heap::into_raw(Box::new(ShellLsTask { + cmd: self.cmd, + opts: self.opts, + print_directory: false, + task_count: self.task_count, + cwd: self.cwd, + path: new_path, + output: Vec::new(), + is_absolute: false, + err: None, + now_secs: 0, + event_loop: self.event_loop, + interp: self.interp, + task: ShellTask::new_child(&self.task), + })); + // SAFETY: freshly allocated above. + unsafe { (*subtask).task.interp = self.interp }; // SAFETY: `task_count` points into the `Box` ExecState which // outlives every in-flight task (see `next`). `subtask` is freshly // heap-allocated and scheduled via raw `WorkPool::schedule` (no @@ -739,6 +749,15 @@ fn civil_from_days(z: i64) -> (i32, u8, u8) { impl bun_event_loop::Taskable for ShellLsTask { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::ShellLsTask; + /// A pool completion that will not run: drop the keep-alive and the box + /// (nothing else frees an unrun one). + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — the box the builtin scheduled. + unsafe { + (*this).task.unref_unrun(); + drop(bun_core::heap::take(this)); + } + } } impl crate::shell::interpreter::ShellTaskCtx for ShellLsTask { diff --git a/src/runtime/shell/builtin/mkdir.rs b/src/runtime/shell/builtin/mkdir.rs index 9f75df4edec0..a4695d61c6b2 100644 --- a/src/runtime/shell/builtin/mkdir.rs +++ b/src/runtime/shell/builtin/mkdir.rs @@ -361,6 +361,15 @@ impl ShellMkdirTask { impl bun_event_loop::Taskable for ShellMkdirTask { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::ShellMkdirTask; + /// A pool completion that will not run: drop the keep-alive and the box + /// (nothing else frees an unrun one). + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — the box the builtin scheduled. + unsafe { + (*this).task.unref_unrun(); + drop(bun_core::heap::take(this)); + } + } } /// Collects each created directory into diff --git a/src/runtime/shell/builtin/mv.rs b/src/runtime/shell/builtin/mv.rs index 2984603809c1..46158deb0695 100644 --- a/src/runtime/shell/builtin/mv.rs +++ b/src/runtime/shell/builtin/mv.rs @@ -651,9 +651,20 @@ impl ShellMvBatchedTask { impl bun_event_loop::Taskable for ShellMvCheckTargetTask { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::ShellMvCheckTargetTask; + /// Owned by the builtin's `MvState`, which frees it with the interpreter; + /// only the keep-alive is this hop's to drop. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract; the Mv state outlives the queue entry. + unsafe { (*this).task.unref_unrun() } + } } impl bun_event_loop::Taskable for ShellMvBatchedTask { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::ShellMvBatchedTask; + /// An element of `MvState::Executing.tasks`; as `ShellMvCheckTargetTask`. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: as above. + unsafe { (*this).task.unref_unrun() } + } } // `*mut Self` sig is forced by the `ShellTaskCtx` trait contract; the body's diff --git a/src/runtime/shell/builtin/rm.rs b/src/runtime/shell/builtin/rm.rs index 3e108781f557..dadc7ee3dbb7 100644 --- a/src/runtime/shell/builtin/rm.rs +++ b/src/runtime/shell/builtin/rm.rs @@ -711,6 +711,8 @@ impl ShellRmTask { let st = &raw mut (*this).task; (*st).task.callback = Self::work_pool_callback; (*st).keep_alive.ref_((*st).event_loop.as_event_loop_ctx()); + // Counted until `ShellTask::on_finish` (see `ShellTask::schedule_no_ref`). + (*st).poster.embedded_work_scheduled(); WorkPool::schedule(&raw mut (*st).task); } } @@ -1391,9 +1393,22 @@ impl DirTask { tm.pending_main_callbacks.fetch_add(1, Ordering::SeqCst); } + // The verbose hop is posted while the rm task is still counted + // work of its VM — i.e. before anything below can let the root + // `finish_concurrently` (which releases that count) run: our + // parent decrement can cascade into it, and for the root it is + // the next statement. `this` may be freed by the JS thread once + // posted, so what we still need is captured first. + let (parent_task, task_manager) = (me.parent_task, me.task_manager); + if will_queue_verbose { + Self::queue_for_write(this); + } else if !parent_task.is_null() { + Self::deinit(this); + } + // If we have a parent and we are the last child, now we can delete the parent. - if !me.parent_task.is_null() { - let p = &*me.parent_task; + if !parent_task.is_null() { + let p = &*parent_task; // The parent releases its own slot on this counter in // `remove_entry_dir`; whoever takes it to 0 owns the // parent's rmdir. The parent's `fetch_sub` is sequenced @@ -1403,24 +1418,14 @@ impl DirTask { // parent's release and makes those writes visible to // `delete_after_waiting_for_children`. if p.subtask_count.fetch_sub(1, Ordering::SeqCst) == 1 { - Self::delete_after_waiting_for_children(me.parent_task); - } - if will_queue_verbose { - Self::queue_for_write(this); - } else { - Self::deinit(this); + Self::delete_after_waiting_for_children(parent_task); } return; } - // Root task. After finish_concurrently() the task may be freed at - // any time unless we hold a pending count, so don't touch - // `this`/task_manager afterwards unless will_queue_verbose kept it - // alive. - ShellRmTask::finish_concurrently(me.task_manager); - if will_queue_verbose { - Self::queue_for_write(this); - } + // Root task: hand it back. It may be freed at any time after + // this unless the verbose hop's pending count keeps it. + ShellRmTask::finish_concurrently(task_manager); } } // Otherwise need to wait. @@ -1470,12 +1475,12 @@ impl DirTask { /// `this` is a live DirTask; the pending-main-callback count on the /// owning ShellRmTask was bumped before calling. unsafe fn queue_for_write(this: *mut DirTask) { - use bun_event_loop::{ConcurrentTask::AutoDeinit, EventLoopTask, EventLoopTaskPtr}; + use bun_event_loop::{ConcurrentTask::AutoDeinit, EventLoopTask}; // SAFETY: caller contract — `this` is live; `task_manager` is live // (pending count > 0). On the early-return path `deinit` reclaims a // non-root Box and `decr_pending_and_maybe_deinit` releases the // pending count taken in `post_run`. - let (me, event_loop) = unsafe { + let (me, poster) = unsafe { let me = &mut *this; if me.deleted_entries.is_empty() { // Deinit non-root and bail. The pending count was already @@ -1489,21 +1494,24 @@ impl DirTask { ShellRmTask::decr_pending_and_maybe_deinit(tm); return; } - let event_loop = (*me.task_manager).event_loop; - (me, event_loop) + let poster = (*me.task_manager).task.poster.clone(); + (me, poster) }; - let task_ptr = match &mut me.concurrent_task { + match &mut me.concurrent_task { EventLoopTask::Js(ct) => { ct.from(this, AutoDeinit::ManualDeinit); - EventLoopTaskPtr { - js: std::ptr::from_mut(ct), - } + // Posted while the rm task is counted work: the VM has not closed. + let bun_jsc::vm_handle::Posted::Queued = + poster.post_js(core::ptr::NonNull::from(ct)) + else { + unreachable!("VM handle closed with shell rm work outstanding"); + }; } - EventLoopTask::Mini(at) => EventLoopTaskPtr { - mini: at.from(this, dir_task_run_from_main_thread_mini), - }, - }; - event_loop.enqueue_task_concurrent(task_ptr); + EventLoopTask::Mini(at) => { + let at = at.from(this, dir_task_run_from_main_thread_mini); + poster.post_mini(core::ptr::NonNull::new(at).expect("intrusive task")); + } + } } /// Flush verbose output. @@ -1689,9 +1697,31 @@ impl RemoveFileHandler for RemoveFileParent { impl bun_event_loop::Taskable for ShellRmTask { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::ShellRmTask; + /// The rm's own completion hop: drop the keep-alive and this pending + /// callback's count (frees the task when it was the last). + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract. + unsafe { + (*this).task.unref_unrun(); + ShellRmTask::decr_pending_and_maybe_deinit(this); + } + } } impl bun_event_loop::Taskable for DirTask { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::ShellRmDirTask; + /// A verbose-output hop: free the (non-root) dir task and give back the + /// pending-callback unit it holds on its rm — as `run_from_main_thread` + /// does when there is nothing to write. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract; capture before the decrement (it may free the root). + unsafe { + let (tm, has_parent) = ((*this).task_manager, !(*this).parent_task.is_null()); + if has_parent { + Self::deinit(this); + } + ShellRmTask::decr_pending_and_maybe_deinit(tm); + } + } } impl crate::shell::interpreter::ShellTaskCtx for ShellRmTask { diff --git a/src/runtime/shell/builtin/touch.rs b/src/runtime/shell/builtin/touch.rs index 31fa8477bd87..95ae1398deee 100644 --- a/src/runtime/shell/builtin/touch.rs +++ b/src/runtime/shell/builtin/touch.rs @@ -321,6 +321,15 @@ impl ShellTouchTask { impl bun_event_loop::Taskable for ShellTouchTask { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::ShellTouchTask; + /// A pool completion that will not run: drop the keep-alive and the box + /// (nothing else frees an unrun one). + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — the box the builtin scheduled. + unsafe { + (*this).task.unref_unrun(); + drop(bun_core::heap::take(this)); + } + } } impl crate::shell::interpreter::ShellTaskCtx for ShellTouchTask { diff --git a/src/runtime/shell/builtin/yes.rs b/src/runtime/shell/builtin/yes.rs index 55525d2d5092..5b97cb46616b 100644 --- a/src/runtime/shell/builtin/yes.rs +++ b/src/runtime/shell/builtin/yes.rs @@ -200,6 +200,9 @@ pub struct YesTask { impl Taskable for YesTask { const TAG: TaskTag = task_tag::ShellYesTask; + /// Lives inside the builtin's `Box` (freed with the interpreter) and + /// took nothing for the bounce; nothing to do. + unsafe fn release_unrun(_: *mut Self) {} } impl YesTask { @@ -220,7 +223,8 @@ impl YesTask { EventLoopTask::Js(ct) => ct.from(this, AutoDeinit::ManualDeinit), EventLoopTask::Mini(_) => unreachable!(), }); - owner.enqueue_task_concurrent(ct); + // Same-thread bounce on the loop's own thread: always accepted. + let _ = owner.js_poster().post(ct); } EventLoopHandle::Mini(mut mini) => { (*mini.loop_).tick(); diff --git a/src/runtime/shell/dispatch_tasks.rs b/src/runtime/shell/dispatch_tasks.rs index 696ab247a5e8..696202ec1274 100644 --- a/src/runtime/shell/dispatch_tasks.rs +++ b/src/runtime/shell/dispatch_tasks.rs @@ -21,96 +21,6 @@ pub(crate) struct ShellAsyncTask { pub concurrent_task: ConcurrentTask, } -/// Posted from the -/// subprocess exit handler back to the JS thread to resume the owning `Cmd`. -#[repr(C)] -pub(crate) struct ShellAsyncSubprocessDone { - pub interp: *mut Interpreter, - pub cmd: NodeId, - pub exit_code: crate::shell::ExitCode, - pub concurrent_task: ConcurrentTask, -} - -impl ShellAsyncSubprocessDone { - /// Reached only via `runtime::dispatch::run_task` for - /// `task_tag::ShellAsyncSubprocessDone`, which always passes the - /// `heap::alloc` payload enqueued by `ShellSubprocess::on_process_exit`. - /// - /// # Safety - /// `this` must be the live `heap::alloc` payload enqueued by - /// `ShellSubprocess::on_process_exit`, and `(*this).interp` must outlive - /// the call. Ownership of `*this` is consumed. - // Dispatch trampoline: `this` validity is guaranteed by the `run_task` - // contract; signature is fixed by `dispatch.rs`. - #[allow(clippy::not_unsafe_ptr_arg_deref)] - pub(crate) fn run_from_main_thread(this: *mut Self) { - // SAFETY: dispatch contract — `this` is the live `heap::alloc` payload - // enqueued by `ShellSubprocess::on_process_exit`; `interp` outlives - // every spawned subprocess. - let (owned, interp) = unsafe { - let owned = bun_core::heap::take(this); - let interp = &*owned.interp; - (owned, interp) - }; - crate::shell::states::cmd::Cmd::on_subprocess_done(interp, owned.cmd, owned.exit_code); - } -} - -/// Defers -/// dropping an [`IOWriter`](crate::shell::io_writer::IOWriter) to the main -/// thread so its `Drop` doesn't race the writer thread. -#[repr(C)] -pub(crate) struct AsyncDeinitWriter { - pub writer: *mut crate::shell::io_writer::IOWriter, - pub concurrent_task: ConcurrentTask, -} - -impl AsyncDeinitWriter { - /// Reached only via `runtime::dispatch::run_task` for - /// `task_tag::ShellIOWriterAsyncDeinit`, which always passes the - /// `heap::alloc` payload enqueued by `IOWriter::async_deinit`. - /// - /// # Safety - /// `this` must be the live `heap::alloc` payload enqueued by - /// `IOWriter::async_deinit`. Ownership of `*this` is consumed. - // Dispatch trampoline: `this` validity is guaranteed by the `run_task` - // contract; signature is fixed by `dispatch.rs`. - #[allow(clippy::not_unsafe_ptr_arg_deref)] - pub(crate) fn run_from_main_thread(this: *mut Self) { - // SAFETY: dispatch contract — `this` is the live `heap::alloc` payload - // enqueued by `IOWriter::async_deinit`. - let owned = unsafe { bun_core::heap::take(this) }; - crate::shell::io_writer::IOWriter::deinit_on_main_thread(owned.writer); - } -} - -/// Defers dropping an [`IOReader`](crate::shell::io_reader::IOReader) to the -/// main thread so its `Drop` doesn't race the reader thread. -#[repr(C)] -pub(crate) struct AsyncDeinitReader { - pub reader: *mut crate::shell::io_reader::IOReader, - pub concurrent_task: ConcurrentTask, -} - -impl AsyncDeinitReader { - /// Reached only via `runtime::dispatch::run_task` for - /// `task_tag::ShellIOReaderAsyncDeinit`, which always passes the - /// `heap::alloc` payload enqueued by `IOReader::async_deinit`. - /// - /// # Safety - /// `this` must be the live `heap::alloc` payload enqueued by - /// `IOReader::async_deinit`. Ownership of `*this` is consumed. - // Dispatch trampoline: `this` validity is guaranteed by the `run_task` - // contract; signature is fixed by `dispatch.rs`. - #[allow(clippy::not_unsafe_ptr_arg_deref)] - pub(crate) fn run_from_main_thread(this: *mut Self) { - // SAFETY: dispatch contract — `this` is the live `heap::alloc` payload - // enqueued by `IOReader::async_deinit`. - let owned = unsafe { bun_core::heap::take(this) }; - crate::shell::io_reader::IOReader::deinit_on_main_thread(owned.reader); - } -} - /// Stat task backing shell conditional expressions (`[ -f x ]` etc.). Wraps an /// inner [`ShellTask`]. #[repr(C)] @@ -167,6 +77,15 @@ pub(crate) struct ShellGlobTask { impl bun_event_loop::Taskable for ShellGlobTask { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::ShellGlobTask; + /// A pool completion that will not run: drop the keep-alive and the box + /// (nothing else frees an unrun one). + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — the box the builtin scheduled. + unsafe { + (*this).task.unref_unrun(); + drop(bun_core::heap::take(this)); + } + } } impl crate::shell::interpreter::ShellTaskCtx for ShellGlobTask { diff --git a/src/runtime/shell/interpreter.rs b/src/runtime/shell/interpreter.rs index 61832e9e8a28..2379323e78cb 100644 --- a/src/runtime/shell/interpreter.rs +++ b/src/runtime/shell/interpreter.rs @@ -1199,20 +1199,26 @@ impl Interpreter { let global_this = self .global_this_ref() .expect("global_this set on Js event-loop path"); - let buffered_stdout = self.get_buffered_stdout(global_this); - let buffered_stderr = self.get_buffered_stderr(global_this); + let buffers = self + .get_buffered_stdout(global_this) + .and_then(|out| Ok((out, self.get_buffered_stderr(global_this)?))); self.keep_alive.with_mut(|k| k.disable()); self.deref_root_shell_and_io_if_needed(true); let _entered = loop_.entered(); - if let Err(err) = resolve.call( - global_this, - JSValue::UNDEFINED, - &[ - JSValue::js_number_from_int32(i32::from(exit_code)), - buffered_stdout, - buffered_stderr, - ], - ) { + let called = buffers.and_then(|(buffered_stdout, buffered_stderr)| { + resolve + .call( + global_this, + JSValue::UNDEFINED, + &[ + JSValue::js_number_from_int32(i32::from(exit_code)), + buffered_stdout, + buffered_stderr, + ], + ) + .map(|_| ()) + }); + if let Err(err) = called { global_this.report_active_exception_as_unhandled(err); } JSShellInterpreter::resolve_set_cached( @@ -1424,7 +1430,7 @@ impl Interpreter { pub(crate) fn get_buffered_stdout( &self, global_this: &crate::jsc::JSGlobalObject, - ) -> crate::jsc::JSValue { + ) -> bun_jsc::JsResult { io_to_js_value( global_this, self.root_shell.with_mut(|rs| rs.buffered_stdout()), @@ -1434,7 +1440,7 @@ impl Interpreter { pub(crate) fn get_buffered_stderr( &self, global_this: &crate::jsc::JSGlobalObject, - ) -> crate::jsc::JSValue { + ) -> bun_jsc::JsResult { io_to_js_value( global_this, self.root_shell.with_mut(|rs| rs.buffered_stderr()), @@ -1570,7 +1576,7 @@ impl Interpreter { fn io_to_js_value( global_this: &crate::jsc::JSGlobalObject, buf: *mut Vec, -) -> crate::jsc::JSValue { +) -> bun_jsc::JsResult { // SAFETY: `buf` points into a live `ShellExecEnv` (root or borrowed). let bytelist = core::mem::take(unsafe { &mut *buf }); // The moved-out `Vec` storage is handed to JSC directly; the @@ -2586,6 +2592,8 @@ pub struct ShellTask { /// no-op`). pub task: WorkPoolTask, pub(crate) event_loop: EventLoopHandle, + /// How the pool thread bounces the task back to its owning loop. + pub(crate) poster: bun_jsc::ConcurrentPoster, pub(crate) keep_alive: bun_io::KeepAlive, /// Back-ref to the owning [`Interpreter`]. The high-tier dispatch /// (`runtime::dispatch::run_task`) recovers `&mut Interpreter` from this @@ -2599,6 +2607,30 @@ pub struct ShellTask { } impl ShellTask { + /// A queued completion that will never run (`Taskable::release_unrun`): + /// drop the keep-alive `schedule` took, as `run_from_main_thread` would have. + pub(crate) fn unref_unrun(&mut self) { + self.keep_alive.unref(self.event_loop.as_event_loop_ctx()); + } + + /// A subtask created on a pool thread (`ls -R` discovering a directory): + /// it reports to the same loop as `parent`, whose poster was captured on + /// the JS thread — nothing here derives one from the VM. + pub(crate) fn new_child(parent: &ShellTask) -> Self { + ShellTask { + task: WorkPoolTask { + node: Default::default(), + callback: shell_task_unset_callback, + }, + poster: parent.poster.clone(), + event_loop: parent.event_loop, + keep_alive: Default::default(), + interp: core::ptr::null_mut(), + concurrent_task: bun_event_loop::EventLoopTask::from_event_loop(parent.event_loop), + } + } + + /// JS thread (the interpreter's): derives the poster for `event_loop`. pub(crate) fn new(event_loop: EventLoopHandle) -> Self { ShellTask { task: WorkPoolTask { @@ -2607,6 +2639,7 @@ impl ShellTask { // fires if a caller forgets the `` (debug-asserted there). callback: shell_task_unset_callback, }, + poster: bun_jsc::ConcurrentPoster::from_event_loop_handle(&event_loop), event_loop, keep_alive: Default::default(), interp: core::ptr::null_mut(), @@ -2646,6 +2679,10 @@ impl ShellTask { unsafe { let this = ctx.byte_add(C::TASK_OFFSET).cast::(); (*this).task.callback = shell_task_trampoline::; + // The context lives in interpreter state a JS wrapper may own: + // counted until `on_finish`, so that VM waits for it (see + // `VmHandle::embedded_work_scheduled`). + (*this).poster.embedded_work_scheduled(); WorkPool::schedule(&raw mut (*this).task); } } @@ -2661,34 +2698,37 @@ impl ShellTask { /// [`schedule`](Self::schedule); not touched again on the worker thread /// after this returns. pub(crate) unsafe fn on_finish(ctx: *mut C) { - use bun_event_loop::{ConcurrentTask::AutoDeinit, EventLoopTask, EventLoopTaskPtr}; + use bun_event_loop::{ConcurrentTask::AutoDeinit, EventLoopTask}; log!("ShellTask onFinish"); // SAFETY: caller contract — `ctx` embeds `ShellTask` at `TASK_OFFSET`. // Stay on raw pointers: once `enqueue_task_concurrent` returns, the // main thread may already be touching `*this`, so no live `&mut` // into it may span that call. `this` is live and exclusively owned by // this thread until the enqueue below. - let (event_loop, task_ptr) = unsafe { + unsafe { let this = ctx.byte_add(C::TASK_OFFSET).cast::(); - let event_loop = (*this).event_loop; - let task_ptr = match &mut (*this).concurrent_task { + let poster = (*this).poster.clone(); + match &mut (*this).concurrent_task { EventLoopTask::Js(ct) => { // Tag resolved via `C: Taskable`. ct.from(ctx, AutoDeinit::ManualDeinit); - EventLoopTaskPtr { - js: std::ptr::from_mut(ct), - } + // Counted work: the VM has not closed its handle. + let bun_jsc::vm_handle::Posted::Queued = + poster.post_js(core::ptr::NonNull::from(ct)) + else { + unreachable!("VM handle closed with shell pool work outstanding"); + }; } EventLoopTask::Mini(at) => { // Pass the monomorphised callback explicitly. - EventLoopTaskPtr { - mini: at.from(this, shell_task_run_from_main_thread_mini::), - } + let at = at.from(this, shell_task_run_from_main_thread_mini::); + poster.post_mini(core::ptr::NonNull::new(at).expect("intrusive task")); } - }; - (event_loop, task_ptr) - }; - event_loop.enqueue_task_concurrent(task_ptr); + } + // The pool side is done with this context (`this` may already be + // freed by the main thread; the poster is ours). + poster.embedded_work_finished(); + } } /// Unrefs the diff --git a/src/runtime/shell/states/Async.rs b/src/runtime/shell/states/Async.rs index ff794ee10b29..91774346209e 100644 --- a/src/runtime/shell/states/Async.rs +++ b/src/runtime/shell/states/Async.rs @@ -150,10 +150,12 @@ impl Async { /// Bounce `run_from_main_thread` through the event loop so the async body runs on subsequent ticks while the /// parent proceeds. fn enqueue_self(interp: &Interpreter, this: NodeId) { - use bun_event_loop::{ConcurrentTask::AutoDeinit, EventLoopTaskPtr}; + use bun_event_loop::ConcurrentTask::AutoDeinit; let me = interp.as_async_mut(this); let task = me.task; debug_assert!(!task.is_null()); + // Same-thread "next tick" bounce through the owning loop's concurrent queue. + let poster = bun_jsc::ConcurrentPoster::from_event_loop_handle(&me.event_loop); match me.event_loop { EventLoopHandle::Js { .. } => { // SAFETY: `task` is the live heap payload allocated in `init` @@ -163,9 +165,9 @@ impl Async { // before the state machine can enqueue again. unsafe { let ct = (*task).concurrent_task.from(task, AutoDeinit::ManualDeinit); - me.event_loop.enqueue_task_concurrent(EventLoopTaskPtr { - js: std::ptr::from_mut(ct), - }); + // This runs on the VM's own thread while it executes, so the + // post is always accepted. + let _ = poster.post_js(core::ptr::NonNull::from(ct)); } } EventLoopHandle::Mini(_) => { @@ -175,8 +177,7 @@ impl Async { task, run_from_main_thread_mini, ); - me.event_loop - .enqueue_task_concurrent(EventLoopTaskPtr { mini: any }); + poster.post_mini(core::ptr::NonNull::new(any).expect("heap task")); } } } @@ -212,6 +213,12 @@ enum NextAction { // enqueued pointer back to `ShellAsyncTask`; both sides MUST agree. impl bun_event_loop::Taskable for crate::shell::dispatch_tasks::ShellAsyncTask { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::ShellAsync; + /// The `Async` node's bounce box, freed only at the end of a chain that + /// will not continue: free it here. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — the box `Async::init` made; nothing else frees an unrun one. + drop(unsafe { bun_core::heap::take(this) }); + } } /// Mini-loop trampoline. diff --git a/src/runtime/shell/states/Cmd.rs b/src/runtime/shell/states/Cmd.rs index 4d64640d065e..2b362fc02636 100644 --- a/src/runtime/shell/states/Cmd.rs +++ b/src/runtime/shell/states/Cmd.rs @@ -850,13 +850,6 @@ impl Cmd { Yield::Next(this) } - /// Main-thread re-entry for a subprocess exit posted from off-thread — - /// equivalent to [`Self::on_exec_done`] but drives the trampoline itself - /// since the dispatcher discards the [`Yield`]. - pub(crate) fn on_subprocess_done(interp: &Interpreter, this: NodeId, exit_code: ExitCode) { - Self::on_exec_done(interp, this, exit_code).run(interp); - } - /// [`Self::deinit`] for the VM-shutdown finalizer: defuses the /// `> ${arraybuffer}` unpins first — the heap sweep already deleted the /// `JSC::ArrayBuffer` impls they would write to. diff --git a/src/runtime/shell/states/CondExpr.rs b/src/runtime/shell/states/CondExpr.rs index 41d010930f17..1850bf77f83f 100644 --- a/src/runtime/shell/states/CondExpr.rs +++ b/src/runtime/shell/states/CondExpr.rs @@ -340,6 +340,15 @@ impl CondExpr { // the enqueued pointer back to `ShellCondExprStatTask`; both sides MUST agree. impl bun_event_loop::Taskable for crate::shell::dispatch_tasks::ShellCondExprStatTask { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::ShellCondExprStatTask; + /// A stat the pool finished whose result will not be applied: drop the + /// keep-alive and the box. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — the box `do_stat` scheduled. + unsafe { + (*this).task.task.unref_unrun(); + drop(bun_core::heap::take(this)); + } + } } impl crate::shell::interpreter::ShellTaskCtx diff --git a/src/runtime/shell/subproc.rs b/src/runtime/shell/subproc.rs index 7f73e723f7c4..a6ea81124a14 100644 --- a/src/runtime/shell/subproc.rs +++ b/src/runtime/shell/subproc.rs @@ -1801,11 +1801,11 @@ impl PipeReader { // on Windows — `start()` goes through `start_with_current_pipe`). let stdio_result = match result { StdioResult::Buffer(buf) => { - reader.source = Some(bun_io::Source::Pipe(buf)); + reader.set_source(bun_io::Source::Pipe(buf)); StdioResult::Unavailable } StdioResult::BufferFd(fd) => { - reader.source = Some(bun_io::Source::File(bun_io::Source::open_file(fd))); + reader.set_source(bun_io::Source::File(bun_io::Source::open_file(fd))); StdioResult::BufferFd(fd) } StdioResult::Unavailable => panic!("Shouldn't happen."), diff --git a/src/runtime/socket/Handlers.rs b/src/runtime/socket/Handlers.rs index 49d90fd8f7e5..4df0cbebcb2d 100644 --- a/src/runtime/socket/Handlers.rs +++ b/src/runtime/socket/Handlers.rs @@ -214,11 +214,6 @@ impl Handlers { // corker: Corker = .{}, pub(crate) fn resolve_promise(&self, value: JSValue) -> JsResult<()> { - let vm = self.vm; - if vm.is_shutting_down() { - return Ok(()); - } - let Some(promise) = self.take_promise() else { return Ok(()); }; @@ -230,11 +225,6 @@ impl Handlers { } pub(crate) fn reject_promise(&self, value: JSValue) -> JsResult { - let vm = self.vm; - if vm.is_shutting_down() { - return Ok(true); - } - let Some(promise) = self.take_promise() else { return Ok(false); }; @@ -259,11 +249,6 @@ impl Handlers { if self.mode != SocketMode::Server { return true; } - // Nothing to release once the process is exiting, and the listener's - // JS wrapper may already be gone. - if self.vm.is_shutting_down() { - return false; - } // Let the listener's JS wrapper be GC'd once the last connection is // closed and it's not listening anymore. if let Some(listener) = self.listener() { @@ -276,11 +261,6 @@ impl Handlers { } pub(crate) fn call_error_handler(&self, this_value: JSValue, args: &[JSValue; 2]) -> bool { - let vm = self.vm; - if vm.is_shutting_down() { - return false; - } - let global_object = self.global_object; // Termination raised inside the preceding callback.call() cannot be // cleared; entering JS again trips executeCallImpl's assertNoException. diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index b167fa4d81d4..d1c3a18fe91a 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -321,6 +321,12 @@ impl Listener { .this_value .with_mut(|r| r.set_strong(this_value, global)); this_ref.poll_ref.with_mut(|p| p.ref_(bun_io::js_vm_ctx())); + if let Some(handles) = crate::jsc_hooks::active_handles() { + bun_core::handle_oom(handles.put( + crate::jsc_hooks::ActiveHandle::Listener(NonNull::from(this_ref)), + (), + )); + } return Ok(this_value); } } @@ -580,6 +586,12 @@ impl Listener { .this_value .with_mut(|r| r.set_strong(this_value, global)); this_ref.poll_ref.with_mut(|p| p.ref_(bun_io::js_vm_ctx())); + if let Some(handles) = crate::jsc_hooks::active_handles() { + bun_core::handle_oom(handles.put( + crate::jsc_hooks::ActiveHandle::Listener(NonNull::from(this_ref)), + (), + )); + } Ok(this_value) } @@ -819,11 +831,23 @@ impl Listener { Ok(JSValue::UNDEFINED) } + /// The VM (or the finished `--isolate` file) is being torn down: stop + /// listening and close accepted connections now, while script can still + /// run their close handlers, instead of from the GC finalizer. + pub(crate) fn stop_for_vm_teardown(this: &Self) { + Self::do_stop(this, true); + } + fn do_stop(this: &Self, force_close: bool) { if matches!(this.listener.get(), ListenerType::None) { return; } let listener = this.listener.replace(ListenerType::None); + if let Some(handles) = crate::jsc_hooks::active_handles() { + handles.swap_remove(&crate::jsc_hooks::ActiveHandle::Listener(NonNull::from( + this, + ))); + } if matches!(listener, ListenerType::Uws(_)) { Self::unlink_unix_socket_path(this); @@ -867,6 +891,13 @@ impl Listener { pub fn finalize(self: Box) { log!("finalize"); let listener = self.listener.replace(ListenerType::None); + if !matches!(listener, ListenerType::None) { + if let Some(handles) = crate::jsc_hooks::active_handles() { + handles.swap_remove(&crate::jsc_hooks::ActiveHandle::Listener(NonNull::from( + &*self, + ))); + } + } match listener { ListenerType::Uws(socket) => { Self::unlink_unix_socket_path(&self); @@ -1725,9 +1756,8 @@ impl WindowsNamedPipeListeningContext { // Shared borrow — `on_name_pipe_created` re-enters JS; the one `&mut` // (the `uv_pipe` field) is taken through the root pointer below. let this_ref = unsafe { &*this }; - let shutting_down = this_ref.vm.is_shutting_down(); - if status != uv::ReturnCode::ZERO || shutting_down || this_ref.listener.is_none() { - // connection dropped or vm is shutting down or we are deiniting/closing + if status != uv::ReturnCode::ZERO || this_ref.listener.is_none() { + // connection dropped, or we are deiniting/closing return; } // `BackRef` deref — owner `Listener` outlives this context (see field doc). @@ -1946,9 +1976,6 @@ pub(crate) extern "C" fn us_dispatch_socket_server_name( return core::ptr::null_mut(); } let handlers = tls.get_handlers(); - if handlers.vm.is_shutting_down() { - return core::ptr::null_mut(); - } let callback = handlers.on_server_name(); if callback.is_empty() { return core::ptr::null_mut(); @@ -2039,9 +2066,6 @@ extern "C" fn us_dispatch_server_name( // duration of this synchronous handshake dispatch. let listener = unsafe { bun_ptr::ThisPtr::new(listener_ptr) }; let handlers = &listener.handlers; - if handlers.vm.is_shutting_down() { - return core::ptr::null_mut(); - } let callback = handlers.on_server_name(); if callback.is_empty() { return core::ptr::null_mut(); diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index 5c428ee9cdb0..af9472e45196 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -212,12 +212,8 @@ impl UpgradedDuplex { } fn call_write_or_end(&self, data: Option<&[u8]>, msg_more: bool) { - // `vm` is always set via `from()`; `None` only in the zeroed placeholder - // state, which never reaches here. - let Some(vm) = self.vm else { return }; - if vm.is_shutting_down() { - return; - } + // No JS duplex to talk to: the zeroed placeholder, or the owning + // socket's finalizer abandoned it (`abandon_js_side`). let duplex = self.origin.get(); if duplex.is_empty() { return; @@ -529,6 +525,15 @@ impl UpgradedDuplex { i32::try_from(encoded_data.len()).expect("int cast") } + /// The owning socket wrapper is being finalized: the JS duplex may be dead + /// too and a finalizer dispatches nothing, so the SSL shutdown that + /// follows writes no close_notify and ends nothing — it only unwinds the + /// native side. + #[uws_callback(export = "UpgradedDuplex__abandon_js_side", no_catch)] + pub(crate) fn abandon_js_side(&self) { + self.origin.set(JSValue::ZERO); + } + #[uws_callback(export = "UpgradedDuplex__close")] pub(crate) fn close(&self) { if let Some(w) = self.wrapper_ref() { diff --git a/src/runtime/socket/WindowsNamedPipe.rs b/src/runtime/socket/WindowsNamedPipe.rs index c117e185c3cd..0db18e4cdbdf 100644 --- a/src/runtime/socket/WindowsNamedPipe.rs +++ b/src/runtime/socket/WindowsNamedPipe.rs @@ -652,6 +652,13 @@ impl WindowsNamedPipe { self.discard_unadopted_pipe(); return Err(e); } + // Until the writer adopts it (start_with_pipe), a thread teardown closes + // this pipe through us; afterwards the writer re-records itself as owner. + uv::open_handles::set_owner( + pipe.cast(), + self.root_ptr().cast(), + Some(Self::stop_for_vm_teardown), + ); // SAFETY: as above. if let Err(e) = server @@ -699,6 +706,13 @@ impl WindowsNamedPipe { self.discard_unadopted_pipe(); return Err(e); } + // Until the writer adopts it (start_with_pipe), a thread teardown closes + // this pipe through us; afterwards the writer re-records itself as owner. + uv::open_handles::set_owner( + pipe.cast(), + self.root_ptr().cast(), + Some(Self::stop_for_vm_teardown), + ); // SAFETY: as above. if let Err(e) = unsafe { (*pipe).open(fd.uv()) }.to_result(bun_sys::Tag::open) { @@ -737,6 +751,13 @@ impl WindowsNamedPipe { self.discard_unadopted_pipe(); return Err(e); } + // Until the writer adopts it (start_with_pipe), a thread teardown closes + // this pipe through us; afterwards the writer re-records itself as owner. + uv::open_handles::set_owner( + pipe.cast(), + self.root_ptr().cast(), + Some(Self::stop_for_vm_teardown), + ); let ctx: *mut Self = self.root_ptr(); let req: *mut uv::uv_connect_t = self.connect_req.as_ptr(); @@ -895,6 +916,19 @@ impl WindowsNamedPipe { i32::try_from(encoded_data.len()).expect("int cast") } + /// `uv::open_handles` closes a not-yet-adopted pipe through here at teardown. + #[cfg(windows)] + unsafe fn stop_for_vm_teardown(this: *mut core::ffi::c_void) { + // SAFETY: recorded right after `pipe.init` by this live object; replaced by + // the writer at adoption or dropped with the pipe (discard_unadopted_pipe). + let this = unsafe { &*this.cast::() }; + if this.flags.get().contains(Flags::PIPE_ADOPTED) { + this.close(); + } else { + this.discard_unadopted_pipe(); + } + } + #[bun_uws::uws_callback(export = "WindowsNamedPipe__close")] pub fn close(&self) { let _ = self.with_wrapper(|w| { @@ -1039,13 +1073,7 @@ impl WindowsNamedPipe { // always succeeds and is a no-op if not reading. unsafe { (*stream).read_stop() }; } - if self.writer.get().get_fd() != Fd::INVALID { - self.writer.with_mut(|w| { - debug_assert!(!w.closed_without_reporting); - w.closed_without_reporting = true; - w.close(); - }); - } + self.writer.with_mut(|w| w.close_without_reporting()); self.writer.with_mut(|w| w.outgoing = Default::default()); } if !self.flags.get().contains(Flags::WRAPPER_BUSY) { diff --git a/src/runtime/socket/WindowsNamedPipeContext.rs b/src/runtime/socket/WindowsNamedPipeContext.rs index 34d12c03d2ce..9ef6e89448ac 100644 --- a/src/runtime/socket/WindowsNamedPipeContext.rs +++ b/src/runtime/socket/WindowsNamedPipeContext.rs @@ -232,6 +232,16 @@ impl WindowsNamedPipeContext { )); } + /// VM stop phase: close the pipe now (its socket's close/error handlers run + /// while script is still allowed) instead of during the final collection. + /// + /// # Safety + /// `this` is a registered live context (see `create`). + pub(crate) unsafe fn stop_for_vm_teardown(this: *mut Self) { + // SAFETY: fn contract; `close` re-enters `on_close`, which may free `this`. + unsafe { (*ptr::addr_of_mut!((*this).named_pipe)).close() }; + } + fn on_error(this: *mut Self, err: &SysError) { // SAFETY: see `on_open`. `is_open`/`socket` are Copy field reads. let (is_open, socket) = unsafe { ((*this).is_open, (*this).socket) }; @@ -286,6 +296,11 @@ impl WindowsNamedPipeContext { // arm; `this` is the live ctx pointer registered in create() match unsafe { (*this).task_event } { EventState::Deinit => { + // SAFETY: `this` is the live allocation registered in create(). + crate::jsc_hooks::ActiveHandle::WindowsNamedPipe(unsafe { + core::ptr::NonNull::new_unchecked(this) + }) + .unregister(); // SAFETY: `this` was allocated via heap::alloc in create(); refcount hit zero // and this deferred task is the sole remaining owner. Drop runs field destructors. drop(unsafe { bun_core::heap::take(this) }); @@ -389,6 +404,14 @@ impl WindowsNamedPipeContext { // Take a +1 intrusive ref so the wrapped JS socket outlives this context. match_socket!(socket, |s: NewSocket| s.ref_()); + // A socket over a Windows named pipe is in no uSockets group: the VM's + // stop phase closes it through this owner (unregistered when freed). + // SAFETY: non-null, fully initialised above. + crate::jsc_hooks::ActiveHandle::WindowsNamedPipe(unsafe { + core::ptr::NonNull::new_unchecked(this) + }) + .register(); + this } } @@ -475,4 +498,10 @@ impl Drop for WindowsNamedPipeContext { #[cfg(windows)] impl bun_event_loop::Taskable for WindowsNamedPipeContext { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::WindowsNamedPipeContext; + /// A `Deinit` hop (refcount already zero) that will not run: `this` is the + /// heap context, freed by nobody else — do what the hop does, script-free. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract. + unsafe { Self::run_event(this) } + } } diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 3c4ff4cba348..1465b20cce40 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -112,7 +112,7 @@ extern "C" fn select_alpn_callback( { let handlers = this.get_handlers(); let callback = handlers.on_alpn_callback(); - if !callback.is_empty() && !handlers.vm.is_shutting_down() && !in_.is_null() && inlen > 0 { + if !callback.is_empty() && !in_.is_null() && inlen > 0 { let scope = handlers.enter(); let global = handlers.global_object; let this_value = this.get_this_value(&global); @@ -330,9 +330,7 @@ impl Drop for CloseTeardown { // Reconnected: `connect_finish` re-armed `this_value`/`poll_ref`, so // skip the idle teardown and only release what we took. this.update_flags(|f| f.remove(Flags::IS_ACTIVE)); - if !VirtualMachine::get().is_shutting_down() { - self.entered.mark_inactive(); - } + self.entered.mark_inactive(); } // Last: this can be the final ref, freeing the socket read above. this.get().deref(); @@ -840,10 +838,6 @@ impl NewSocket { pub(crate) fn handle_error(&self, err_value: JSValue) { log!("handleError"); let handlers = self.get_handlers(); - let vm = handlers.vm; - if vm.is_shutting_down() { - return; - } // the handlers must be kept alive for the duration of the function call // that way if we need to call the error handler, we can let scope = handlers.enter(); @@ -881,10 +875,6 @@ impl NewSocket { return; } - let vm = handlers.vm; - if vm.is_shutting_down() { - return; - } // Hold the socket alive for the rest of the dispatch: `internal_flush` // and the drain callback can both re-enter JS and close it. let _keepalive = this.ref_guard(); @@ -974,9 +964,6 @@ impl NewSocket { if callback.is_empty() || this.flags.get().contains(Flags::FINALIZING) { return; } - if handlers.vm.is_shutting_down() { - return; - } // the handlers must be kept alive for the duration of the function call // that way if we need to call the error handler, we can @@ -1484,19 +1471,22 @@ impl NewSocket { } let handlers = this.get_handlers(); + let global = handlers.global_object; + // The socket is live whether or not script may run: give it its owner + // (the wrapper adopts the creation reference; its finalizer releases + // it) and account for it now, so one that opens under a stop already + // requested is closed by the sweep and freed like any other instead of + // keeping a reference nobody holds. + let this_value = this.get_this_value(&global); + this.mark_active(); // Multiple JS entries below; a worker terminate() raised in one trips - // assertNoException() in the next, and is_shutting_down() is still - // false at that point. + // assertNoException() in the next. if handlers.vm.script_execution_status() != jsc::ScriptExecutionStatus::Running { return; } let callback = handlers.on_open(); let handshake_callback = handlers.on_handshake(); - let global = handlers.global_object; - let this_value = this.get_this_value(&global); - - this.mark_active(); if let Err(e) = handlers.resolve_promise(this_value) { // Event-loop dispatch: returning with the exception still pending // would leak it into the next native JSC call in this tick. @@ -1641,8 +1631,7 @@ impl NewSocket { let _keepalive = this.ref_guard(); let callback = handlers.on_end(); - let vm = handlers.vm; - if callback.is_empty() || vm.is_shutting_down() { + if callback.is_empty() { this.poll_ref.with_mut(|p| p.unref(js_loop_ctx())); // If you don't handle TCP fin, we assume you're done. @@ -1673,8 +1662,10 @@ impl NewSocket { // A late event on a socket that already released its Handlers through // a path that did not route back through this dispatch - e.g. a // JS-side destroy on a TLS socket driven by an upgraded duplex. There - // is nothing to dispatch to. - if !this.has_handlers() { + // is nothing to dispatch to. Nor from the GC finalizer's own close + // (an SSL shutdown reports the never-finished handshake): a finalizer + // dispatches nothing and may not inspect other cells mid-sweep. + if !this.has_handlers() || this.flags.get().contains(Flags::FINALIZING) { return; } this.update_flags(|f| f.insert(Flags::HANDSHAKE_COMPLETE)); @@ -2083,7 +2074,6 @@ impl NewSocket { return; } - let vm = handlers.vm; this.poll_ref.with_mut(|p| p.unref(js_loop_ctx())); let callback = handlers.on_close(); @@ -2093,11 +2083,6 @@ impl NewSocket { return; } - if vm.is_shutting_down() { - drop(cleanup); - return; - } - // An earlier callback in this dispatch may have left a termination // pending — on_open's error branch closes the socket from // mark_inactive(), landing here. Entering JS trips assertNoException(). @@ -2168,9 +2153,6 @@ impl NewSocket { if callback.is_empty() || this.flags.get().contains(Flags::FINALIZING) { return; } - if handlers.vm.is_shutting_down() { - return; - } let global = handlers.global_object; let this_value = this.get_this_value(&global); @@ -3287,6 +3269,7 @@ impl NewSocket { this_ref.update_flags(|f| f.insert(Flags::FINALIZING)); this_ref.this_value.with_mut(|r| r.finalize()); if !this_ref.socket.get().is_closed() { + this_ref.socket.get().prepare_for_finalize(); this_ref.close_and_detach(uws::CloseCode::Failure); } else { this_ref.detach_native_callback(); @@ -4211,6 +4194,23 @@ impl SocketMode { impl bun_event_loop::Taskable for DuplexUpgradeContext { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::DuplexUpgradeContext; + /// The context's one queued hop will not run, and nothing else frees the + /// context. If the TLSSocket is still attached (a `StartTLS` that never + /// ran: no wrapper was created, so no close ever detached it), route it + /// through its close first — that consumes our +1 and detaches it from the + /// duplex, so its finalizer during ~VM finds nothing to reach into — then + /// free the context. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract; the single queue entry for `this`. + unsafe { + (*this).queued = false; + if let Some(tls) = (*this).tls.take() { + let socket = Self::duplex_socket(this); + TLSSocket::on_close(tls.into_this_ptr(), socket, 0, None); + } + Self::deinit(this); + } + } } pub(crate) struct DuplexUpgradeContext { @@ -4223,6 +4223,11 @@ pub(crate) struct DuplexUpgradeContext { /// through the safe `event_loop_mut()` accessor instead of a raw deref. pub vm: &'static VirtualMachine, pub(in crate::socket) task_event: EventState, + /// A `task_event` hop is in the VM's queue. The context is enqueued by + /// pointer, so at most one entry may exist: a second request while queued + /// (the stop phase closing a duplex whose StartTLS has not run) only + /// updates `task_event`, which the pending entry reads when it runs. + queued: bool, /// Config to build a fresh `SSL_CTX` from (legacy `{ca,cert,key}` callers). /// Mutually exclusive with `owned_ctx` — `runEvent` prefers `owned_ctx`. pub ssl_config: Option, @@ -4420,6 +4425,8 @@ impl DuplexUpgradeContext { /// callers must not hold a `&`/`&mut Self` across the call — pass the raw /// pointer directly so no Stacked Borrows protector spans the dealloc. pub(crate) unsafe fn run_event(this: *mut Self) { + // SAFETY: `this` is live; disjoint field write. + unsafe { (*this).queued = false }; // SAFETY: `this` is live; copy of a `Copy` field. match unsafe { (*this).task_event } { EventState::StartTLS => { @@ -4523,6 +4530,10 @@ impl DuplexUpgradeContext { unsafe fn enqueue_self_task(this: *mut Self) { // SAFETY: fn contract; `vm` is process-lifetime, borrow ends at `;`. unsafe { + if core::mem::replace(&mut (*this).queued, true) { + // Already in the queue: that entry runs the updated `task_event`. + return; + } (*this) .vm .event_loop_mut() @@ -4548,6 +4559,17 @@ impl DuplexUpgradeContext { unsafe { Self::enqueue_self_task(this) }; } + /// VM stop phase: close the upgraded duplex natively, so the TLS wrapper's + /// GC finalizer finds a closed socket and dispatches nothing. + /// + /// # Safety + /// `this` is a registered live context (see `js_upgrade_duplex_to_tls`). + pub(crate) unsafe fn stop_for_vm_teardown(this: *mut Self) { + // SAFETY: fn contract; `close` may re-enter and free `this` through + // the normal on_close → deinit path, so nothing is touched afterwards. + unsafe { (*this).upgrade.close() }; + } + /// # Safety /// `this` must be the unique live pointer to the heap allocation produced /// in `js_upgrade_duplex_to_tls`. Frees the allocation; callers must not @@ -4555,6 +4577,11 @@ impl DuplexUpgradeContext { /// be a Stacked Borrows protector violation when the backing `Box` is /// reclaimed below). unsafe fn deinit(this: *mut Self) { + // SAFETY: fn contract — the live allocation registered in `js_upgrade_duplex_to_tls`. + crate::jsc_hooks::ActiveHandle::DuplexUpgrade(unsafe { + core::ptr::NonNull::new_unchecked(this) + }) + .unregister(); { // SAFETY: `this` is live; each field access is scoped to its own // statement, so nothing spans the `heap::take` free below. @@ -4774,6 +4801,7 @@ pub fn js_upgrade_duplex_to_tls( ptr::addr_of_mut!((*duplex_context).tls).write(Some(IntrusiveRc::from_raw(tls.as_ptr()))); ptr::addr_of_mut!((*duplex_context).vm).write(VirtualMachine::get()); ptr::addr_of_mut!((*duplex_context).task_event).write(EventState::StartTLS); + ptr::addr_of_mut!((*duplex_context).queued).write(false); // When `owned_ctx` is set, `runEvent` builds from it and ignores // `ssl_config` for SSL_CTX construction; servername/ALPN already // copied onto `tls` above so the config's only remaining use is the @@ -4862,6 +4890,14 @@ pub fn js_upgrade_duplex_to_tls( // dangling still exits. If the underlying stream is a real socket, that // socket's own handle keeps the loop alive. + // A TLS socket over a JS duplex is in no uSockets group, so the VM's stop + // phase closes it through this owner (see `stop_for_vm_teardown`) rather + // than leaving it to a GC finalizer. + // SAFETY: non-null, fully initialised; unregistered again in `deinit`. + crate::jsc_hooks::ActiveHandle::DuplexUpgrade(unsafe { + core::ptr::NonNull::new_unchecked(duplex_context) + }) + .register(); // SAFETY: `duplex_context` is the freshly built live allocation. unsafe { DuplexUpgradeContext::start_tls(duplex_context) }; diff --git a/src/runtime/socket/udp_socket.rs b/src/runtime/socket/udp_socket.rs index 368db401045b..8c8b8367f18e 100644 --- a/src/runtime/socket/udp_socket.rs +++ b/src/runtime/socket/udp_socket.rs @@ -82,6 +82,7 @@ unsafe extern "C" { extern "C" fn on_close(socket: *mut uws::udp::Socket) { let this: &UDPSocket = UDPSocket::from_uws(socket); this.closed.set(true); + crate::jsc_hooks::ActiveHandle::UdpSocket(core::ptr::NonNull::from(this)).unregister(); this.poll_ref.with_mut(|p| p.disable()); this.this_value.with_mut(|r| r.downgrade()); this.socket.set(None); @@ -685,6 +686,8 @@ impl UDPSocket { this.socket.set(if created.is_null() { None } else { + // Open: the VM's stop phase closes it if script never does. + crate::jsc_hooks::ActiveHandle::UdpSocket(core::ptr::NonNull::from(this)).register(); Some(created) }); @@ -787,7 +790,7 @@ impl UDPSocket { } else { this_value_ }; - let callback = js::on_error_get_cached(this_value).unwrap_or(JSValue::ZERO); + let callback = js::on_error_get_cached(this_value).unwrap_or_default(); let global_this = self.global_this.get(); let vm = global_this.bun_vm().as_mut(); @@ -1667,11 +1670,22 @@ impl UDPSocket { Ok(JSValue::UNDEFINED) } + /// The VM's stop phase (script forbidden): close the uSockets socket, as + /// `close()` from script would; `on_close` unregisters and drops the keep-alive. + pub(crate) fn stop_for_vm_teardown(this: &Self) { + Self::close_socket(this); + } + #[bun_jsc::host_fn(method)] pub fn close(this: &Self, _: &JSGlobalObject, _: &CallFrame) -> JsResult { + Self::close_socket(this); + Ok(JSValue::UNDEFINED) + } + + fn close_socket(this: &Self) { if !this.closed.get() { let Some(socket) = this.socket.take() else { - return Ok(JSValue::UNDEFINED); + return; }; // `(*socket).close()` SYNCHRONOUSLY invokes `on_close` (udp.c:110 // `s->on_close(s)`), which re-derives `&UDPSocket` from the uws @@ -1688,8 +1702,6 @@ impl UDPSocket { // `Socket` is an `opaque_ffi!` ZST — `opaque_mut` is the safe deref. uws::udp::Socket::opaque_mut(socket).close(); } - - Ok(JSValue::UNDEFINED) } #[bun_jsc::host_fn(method)] diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index 7dc3edc60f3c..cf95b89cca37 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -490,7 +490,7 @@ impl Expect { promise.set_handled(vm); // SAFETY: bun_vm() returns the live thread-local VirtualMachine. - global_this.bun_vm().as_mut().wait_for_promise(promise); + global_this.bun_vm().as_mut().wait_for_promise(promise)?; let new_value = promise.result(vm); match promise.status() { @@ -893,8 +893,9 @@ impl Expect { } if let Some(promise) = return_value.as_any_promise() { - vm.wait_for_promise(promise); + let waited = vm.wait_for_promise(promise); scope.apply(vm); + waited?; match promise.unwrap(global_this.vm(), js_promise::UnwrapMode::MarkHandled) { js_promise::Unwrapped::Fulfilled(_) => { return Ok((None, return_value_from_function)); @@ -1488,7 +1489,7 @@ impl Expect { promise.set_handled(vm); // SAFETY: bun_vm() returns the live thread-local VirtualMachine. - global_this.bun_vm().as_mut().wait_for_promise(promise); + global_this.bun_vm().as_mut().wait_for_promise(promise)?; result = promise.result(vm); result.ensure_still_alive(); diff --git a/src/runtime/timer/mod.rs b/src/runtime/timer/mod.rs index fc4cb96665e4..f6cb91c51568 100644 --- a/src/runtime/timer/mod.rs +++ b/src/runtime/timer/mod.rs @@ -695,6 +695,25 @@ impl All { } } + /// The owning thread's JSC VM is gone (nothing schedules a WTFTimer any + /// more) and the timeout objects are drained: hand the embedded + /// `uv_timer_t`/`uv_idle_t` to `uv_close` so their nodes leave the loop's + /// handle queue when the teardown closes the loop — before this struct's + /// storage is freed. + #[cfg(windows)] + pub(crate) fn close_loop_handles_for_vm_teardown(&mut self) { + unsafe extern "C" fn timer_closed(_: *mut uv::Timer) {} + unsafe extern "C" fn idle_closed(_: *mut uv::uv_idle_t) {} + if !self.uv_timer.data.is_null() { + self.uv_timer.stop(); + self.uv_timer.close(timer_closed); + } + if !self.uv_idle.data.is_null() { + self.uv_idle.stop(); + self.uv_idle.close(idle_closed); + } + } + /// Lazily `uv_timer_init` the /// per-`All` libuv timer, then (re)start it for the soonest deadline /// across both heaps. On Windows there is no epoll/kqueue fallback; this @@ -715,6 +734,10 @@ impl All { bun_jsc::virtual_machine::VirtualMachine::get_mut_ptr().cast::(); self.uv_timer.unref(); } + debug_assert!( + !self.uv_timer.is_closing(), + "timer scheduled after teardown closed the heap's uv timer" + ); let reg_next = self.timers.peek().map(|timer| { // SAFETY: `peek` returns a live heap node. @@ -1170,6 +1193,41 @@ impl All { let _ = uws_loop; } + /// VM teardown, after `cancel_all_timeout_objects`: unlink every timer still + /// in either heap, whatever its kind. Owners keep their nodes (now + /// `CANCELLED`, which their own `state == ACTIVE` checks respect); nothing + /// can fire afterwards even if the loop turns again. + /// + /// # Safety + /// `this` is the live per-thread `All`; JS thread; never on a VM that keeps running. + pub(crate) unsafe fn disarm_all_for_vm_teardown(this: *mut Self) { + let mut nodes: Vec<*mut EventLoopTimer> = Vec::new(); + let mut stack: Vec<*mut EventLoopTimer> = Vec::new(); + // SAFETY: fn contract. + let roots = unsafe { [(*this).timers.0.root, (*this).fake_timers.timers.0.root] }; + for root in roots { + if !root.is_null() { + stack.push(root); + } + } + while let Some(node) = stack.pop() { + // SAFETY: intrusive-heap invariant — reachable nodes are live while linked. + let (child, next) = unsafe { ((*node).heap.child, (*node).heap.next) }; + if !child.is_null() { + stack.push(child); + } + if !next.is_null() { + stack.push(next); + } + nodes.push(node); + } + for node in nodes { + // SAFETY: collected from the live heap above; `remove` relinks the + // others but every node stays a valid allocation owned elsewhere. + unsafe { (*this).remove(node) }; + } + } + /// VM-teardown pass: `cancel()` every `TimeoutObject` / `ImmediateObject` /// still linked in `timers` / `fake_timers.timers` so the in-heap `+1` ref /// and the JS pin (`this_value` Strong) are released before the GC sweep. diff --git a/src/runtime/timer/timer_object_internals.rs b/src/runtime/timer/timer_object_internals.rs index 181f20e8dffb..3f9d3e4332e6 100644 --- a/src/runtime/timer/timer_object_internals.rs +++ b/src/runtime/timer/timer_object_internals.rs @@ -366,7 +366,10 @@ impl TimerObjectInternals { // `s.deref()` below; `*this` may be freed only after that point. let s = unsafe { &*this }; let cleared = s.flags.get().has_cleared_timer() + // The VM's stop was requested: nothing more enters script (as `fire`). // SAFETY: `vm` is the live per-thread VM (hook contract). + || unsafe { (*vm).script_execution_status() } != ScriptExecutionStatus::Running + // SAFETY: as above. || s.generation != unsafe { (*vm).test_isolation_generation } // unref'd setImmediate callbacks should only run if there are things // keeping the event loop alive other than setImmediates diff --git a/src/runtime/valkey_jsc/js_valkey.rs b/src/runtime/valkey_jsc/js_valkey.rs index 8c68f4c82ed3..b2a479cb66e2 100644 --- a/src/runtime/valkey_jsc/js_valkey.rs +++ b/src/runtime/valkey_jsc/js_valkey.rs @@ -1201,6 +1201,8 @@ impl JSValkeyClient { return; } + // No reconnecting on a VM that is exiting: its stop phase would only + // have to close the new socket again. if self.vm().is_shutting_down() { bun_core::hint::cold(); return; @@ -1450,14 +1452,6 @@ impl JSValkeyClient { return; } - // During VM shutdown the event loop won't tick, so the deferred task below - // would never run; close inline (this_value is cleared, no JS re-entry). - if self.vm().is_shutting_down() { - bun_core::hint::cold(); - self.client_mut().close(); - return; - } - self.ref_(); // socket close can potentially call JS so we need to enqueue the deinit let task = jsc::Task::from_boxed(Box::new(ValkeyDeferredClose { @@ -2098,4 +2092,9 @@ impl ValkeyDeferredClose { impl bun_event_loop::Taskable for ValkeyDeferredClose { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::ValkeyDeferredClose; + /// The deferred close is script-free bookkeeping; do it. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — boxed at the enqueue site. + unsafe { bun_core::heap::take(this) }.run(); + } } diff --git a/src/runtime/valkey_jsc/protocol_jsc.rs b/src/runtime/valkey_jsc/protocol_jsc.rs index b1cec30f944c..498d7b9eaf85 100644 --- a/src/runtime/valkey_jsc/protocol_jsc.rs +++ b/src/runtime/valkey_jsc/protocol_jsc.rs @@ -79,10 +79,7 @@ fn valkey_str_to_js_value( // The parser's payload is an owned allocation that is only converted // once; adopt it as the Buffer backing store instead of copying it // into a fresh ArrayBuffer. - Ok(JSValue::create_buffer_from_box( - global, - core::mem::take(str), - )) + JSValue::create_buffer_from_box(global, core::mem::take(str)) } else { bun_string_jsc::create_utf8_for_js(global, str) } diff --git a/src/runtime/webcore/ArrayBufferSink.rs b/src/runtime/webcore/ArrayBufferSink.rs index 1eafec641957..60f8982cc05d 100644 --- a/src/runtime/webcore/ArrayBufferSink.rs +++ b/src/runtime/webcore/ArrayBufferSink.rs @@ -1,5 +1,6 @@ use crate::webcore::streams::{self, SourceHandle}; use bun_collections::{ByteVecExt, VecExt}; +use bun_jsc::HostReturn as _; use bun_jsc::{ArrayBuffer, JSGlobalObject, JSType, JSValue, JsResult}; use bun_sys as syscall; @@ -52,16 +53,14 @@ impl ArrayBufferSink { _wait: bool, ) -> bun_sys::Result { if self.streaming { - // TODO: properly propagate exception upwards. - let value: JSValue = if self.as_uint8array { + let value = if self.as_uint8array { ArrayBuffer::create::<{ JSType::Uint8Array }>(global_this, self.bytes.slice()) - .unwrap_or(JSValue::ZERO) } else { ArrayBuffer::create::<{ JSType::ArrayBuffer }>(global_this, self.bytes.slice()) - .unwrap_or(JSValue::ZERO) }; self.bytes.clear(); - return Ok(value); + // Host return: empty ⇒ the exception `create` left pending. + return Ok(value.or_pending_exception()); } Ok(JSValue::js_number(0.0)) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index ce3ead8dc495..6a6151444011 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -485,25 +485,14 @@ impl BlobExt for Blob { handler, ) .unwrap_or_else(|e| bun_core::handle_oom(Err(e))); - let read_file_task = read_file::ReadFileTask::create_on_js_thread( - global, - bun_core::heap::into_raw(file_read), - ); - // Create the Promise only after the store has been ref()'d. - // The garbage collector runs on memory allocations - // The JSPromise is the next GC'd memory allocation. - // This shouldn't really fix anything, but it's a little safer. - // `JSPromiseStrong.strong` is private; `init` creates the - // JSPromise *and* the strong handle in one step. // SAFETY: handler was just boxed; sole owner. unsafe { (*handler).promise = jsc::JSPromiseStrong::init(global) }; // SAFETY: same `handler` as above; still solely owned here. let promise_value = unsafe { (*handler).promise.value() }; promise_value.ensure_still_alive(); - // SAFETY: `read_file_task` was just heap-allocated by `create_on_js_thread`. - read_file::ReadFileTask::schedule(unsafe { &mut *read_file_task }); + read_file::ReadFile::schedule(file_read, global); debug!("doReadFile: read_file_task scheduled"); promise_value @@ -699,12 +688,7 @@ impl BlobExt for Blob { self.size.get(), ) .unwrap_or_else(|e| bun_core::handle_oom(Err(e))); - let read_file_task = read_file::ReadFileTask::create_on_js_thread( - global, - bun_core::heap::into_raw(file_read), - ); - // SAFETY: `read_file_task` was just heap-allocated by `create_on_js_thread`. - read_file::ReadFileTask::schedule(unsafe { &mut *read_file_task }); + read_file::ReadFile::schedule(file_read, global); } } fn get_content_type(&self) -> Option { @@ -4725,17 +4709,13 @@ pub(crate) fn write_file_with_source_destination( options.mkdirp_if_not_exists.unwrap_or(true), ) .expect("unreachable"); - let task = write_file_mod::WriteFileTask::create_on_js_thread(ctx, file_copier); // Defer promise creation until we're just about to schedule the task. - // `JSPromiseStrong.strong` is private in `bun_jsc`, so use `init` (which - // creates the JSPromise *and* the strong handle in one step). // SAFETY: write_file_promise was just produced by heap::alloc above; sole owner. unsafe { (*write_file_promise).promise = jsc::JSPromiseStrong::init(ctx) }; // SAFETY: same `write_file_promise` as above; still solely owned here. let promise_value = unsafe { (*write_file_promise).promise.value() }; promise_value.ensure_still_alive(); - // SAFETY: `task` was just heap-allocated by `create_on_js_thread`. - write_file_mod::WriteFileTask::schedule(unsafe { &mut *task }); + write_file_mod::WriteFile::schedule(file_copier, ctx); return Ok(promise_value); } } @@ -4754,7 +4734,7 @@ pub(crate) fn write_file_with_source_destination( } #[cfg(not(windows))] { - let mut file_copier = copy_file::CopyFile::create( + return Ok(copy_file::CopyFile::create( destination_store, source_store, destination_blob.offset.get(), @@ -4762,14 +4742,7 @@ pub(crate) fn write_file_with_source_destination( ctx, options.mkdirp_if_not_exists.unwrap_or(true), options.mode, - ); - file_copier.schedule(); - // `ConcurrentPromiseTask` is consumed by the work-pool and freed via - // `ManualDeinit` → `destroy(*mut Self)`, so hand ownership over as a - // raw pointer (paired with `heap::take` in `destroy()`). - let promise_value = file_copier.promise.value(); - let _ = bun_core::heap::into_raw(file_copier); - return Ok(promise_value); + )); } } else if destination_type == store::DataTag::File && source_type == store::DataTag::S3 { let s3 = source_store.data.as_s3(); diff --git a/src/runtime/webcore/CompressionStreamCoder.rs b/src/runtime/webcore/CompressionStreamCoder.rs index 53077a41de94..647a3387cd5c 100644 --- a/src/runtime/webcore/CompressionStreamCoder.rs +++ b/src/runtime/webcore/CompressionStreamCoder.rs @@ -13,9 +13,8 @@ use core::ffi::c_int; use core::ptr::{self, NonNull}; use bun_jsc::ZigStringJsc as _; -use bun_jsc::work_task::{WorkTask, WorkTaskContext}; use bun_jsc::zig_string::ZigString as JscZigString; -use bun_jsc::{ErrorCode, JSGlobalObject, JSUint8Array, JSValue, JsTerminated, Strong}; +use bun_jsc::{ErrorCode, JSGlobalObject, JSUint8Array, JSValue, Strong}; use bun_brotli::c as brotli; use bun_zlib as zlib; @@ -572,57 +571,63 @@ impl CompressionStreamCoder { } } -/// Input bytes for an off-thread codec step. When the chunk is a pinnable -/// `ArrayBuffer`/view, the backing store is pinned (cannot be detached) and -/// the `JSValue` is `protect()`ed (cannot be collected) so the worker thread -/// reads the bytes in place; otherwise the bytes are copied. `Drop` releases -/// both on the JS thread (the ctx box is reclaimed in `then`). +/// A chunk's bytes for the pool thread: a pinned ArrayBuffer's backing +/// store (its pin/protect is the paired [`PinnedChunk`] on the JS side) or an +/// owned copy. pub(crate) enum AsyncInput { - Pinned { - value: JSValue, - ptr: *const u8, - len: usize, - }, + Pinned { ptr: *const u8, len: usize }, Owned(Vec), } - -// SAFETY: `Pinned.ptr` borrows a JS ArrayBuffer backing store that is pinned -// and GC-protected for the lifetime of this value; the worker only reads -// through it. The `JSValue` word is only dereferenced (unpin/unprotect) back -// on the JS thread in `Drop`. +// SAFETY: `Pinned.ptr` is a backing store pinned + protected by the paired +// `PinnedChunk` for as long as the job lives; read only under the pool borrow. unsafe impl Send for AsyncInput {} +/// The pin + GC protection on a chunk whose bytes went to the pool; released +/// on drop (JS thread, with the job's Js side). +pub(crate) struct PinnedChunk(JSValue); +// SAFETY: pin/protect on a heap cell; gone with the heap. +unsafe impl bun_jsc::job::JsAffine for PinnedChunk {} +impl Drop for PinnedChunk { + fn drop(&mut self) { + self.0.unpin_array_buffer(); + self.0.unprotect(); + } +} + impl AsyncInput { - /// Pin `chunk`'s backing store and GC-protect it, borrowing its bytes; or - /// copy `fallback` when `chunk` is not a pinnable BufferSource (the - /// string → `WTF::CString`-scratch branch of `bufferSourceBytes`). - pub(crate) fn new(global: &JSGlobalObject, chunk: JSValue, fallback: &[u8]) -> Self { + /// JS thread: pin `chunk` if it is a pinnable ArrayBuffer/view, else copy `fallback`. + pub(crate) fn new( + global: &JSGlobalObject, + chunk: JSValue, + fallback: &[u8], + ) -> (Self, Option) { if let Some(buf) = chunk.as_pinned_arraybuffer(global) { // A resizable non-shared backing can `mprotect()` pages out on // `resize()`; pinning does not block that, so spill to a copy. if buf.resizable && !buf.shared { chunk.unpin_array_buffer(); - return Self::Owned(fallback.to_vec()); + return (Self::Owned(fallback.to_vec()), None); } chunk.protect(); - return Self::Pinned { - value: chunk, - ptr: buf.ptr, - len: buf.byte_len, - }; + return ( + Self::Pinned { + ptr: buf.ptr, + len: buf.byte_len, + }, + Some(PinnedChunk(chunk)), + ); } - Self::Owned(fallback.to_vec()) + (Self::Owned(fallback.to_vec()), None) } #[inline] pub(crate) fn slice(&self) -> &[u8] { match self { - Self::Pinned { ptr, len, .. } => { + Self::Pinned { ptr, len } => { if ptr.is_null() { return &[]; } - // SAFETY: backing store is pinned + GC-protected for `self`'s - // lifetime; `(ptr, len)` came from a live `ArrayBuffer` view. + // SAFETY: see the `Send` note. unsafe { core::slice::from_raw_parts(*ptr, *len) } } Self::Owned(v) => v.as_slice(), @@ -630,15 +635,6 @@ impl AsyncInput { } } -impl Drop for AsyncInput { - fn drop(&mut self) { - if let Self::Pinned { value, .. } = *self { - value.unpin_array_buffer(); - value.unprotect(); - } - } -} - // ─── extern "C" surface (called from JSCompressionStream.cpp) ────────────── #[unsafe(no_mangle)] @@ -799,73 +795,79 @@ unsafe extern "C" { ); } +/// One large `CompressionStream`/`DecompressionStream` chunk transformed off +/// the JS thread. pub struct CompressionAsyncCtx { /// Holds one coder reference (taken in `__transformAsync`, released by - /// `Drop`); see [`CompressionStreamCoder::ref_count`]. + /// `Drop`); see [`CompressionStreamCoder::ref_count`]. TransformStream + /// serializes writes, so nothing else touches it while the pool has it. coder: *mut CompressionStreamCoder, input: AsyncInput, finish: bool, - /// GC root for the `JSTransformStream` cell that owns `coder`; its - /// `m_asyncCodecInFlight` flag defers the eager ClearAlgorithms release - /// while this task holds it, and its `m_asyncCodecPromise` WriteBarrier - /// keeps the pending transform-algorithm promise alive. - stream: Strong, error: Option, } impl Drop for CompressionAsyncCtx { fn drop(&mut self) { // SAFETY: `coder` was ref'd in `__transformAsync`; this ctx owns that - // reference and drops exactly once (JS thread, in `then` or the - // shutdown drain). + // reference and drops it exactly once (in `then`, or when the job is + // released unrun / its off-thread part finishes after the VM is gone). unsafe { bun_ptr::ThreadSafeRefCount::::deref(self.coder) }; } } -pub type CompressionStreamCoderTask = WorkTask; +// SAFETY: the coder is `ThreadSafeRefCounted` and only touched by whoever holds +// the transform (pool thread, then JS thread); `AsyncInput` owns or pins its bytes. +unsafe impl Send for CompressionAsyncCtx {} -#[allow(clippy::not_unsafe_ptr_arg_deref)] -impl WorkTaskContext for CompressionAsyncCtx { - const TASK_TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::CompressionStreamCoderTask; - - fn run(this: *mut Self, task: *mut WorkTask) { - // SAFETY: work-pool hand-off; `this`/`task` are live and exclusive. - // `coder` is kept alive by the reference this ctx holds (the cell's - // finalizer only releases its own), and TransformStream serializes - // writes so nothing else aliases it. - unsafe { - let ctx = &mut *this; - ctx.error = (*ctx.coder).transform(ctx.input.slice(), ctx.finish).err(); - WorkTask::on_finish(&mut *task); - } +#[derive(bun_jsc::JsAffine)] +pub struct CompressionAsyncJs { + /// GC root for the `JSTransformStream` cell; its `m_asyncCodecInFlight` + /// flag defers the eager ClearAlgorithms release while this task holds it, + /// and its `m_asyncCodecPromise` WriteBarrier keeps the pending + /// transform-algorithm promise alive. + stream: Strong, + _pin: Option, +} + +impl bun_jsc::JobContext for CompressionAsyncCtx { + type OffThread = Self; + type Js = CompressionAsyncJs; + + fn run( + this: &mut Self, + _vm: &bun_jsc::vm_handle::Borrow, + done: bun_jsc::Completion, + ) -> Option> { + // SAFETY: `coder` is kept alive by the reference this ctx holds (the + // cell's finalizer only releases its own); see the field doc. + this.error = unsafe { (*this.coder).transform(this.input.slice(), this.finish) }.err(); + Some(done) } - fn then(this: *mut Self, global: &JSGlobalObject) -> Result<(), JsTerminated> { - // SAFETY: heap-allocated in `__transformAsync`; consumed here so - // `input` (unpin/unprotect) and `stream` drop on the JS thread. - let ctx = unsafe { bun_core::heap::take(this) }; - let stream = ctx.stream.get(); - let (out, out_len, err) = match ctx.error { + fn then( + this: Self, + js: CompressionAsyncJs, + cx: &bun_jsc::JsThread<'_>, + ) -> bun_jsc::JsResult<()> { + let global = cx.global(); + let (out, out_len, err) = match &this.error { None => { - // SAFETY: `ctx` holds a coder reference until it drops at the + // SAFETY: `this` holds a coder reference until it drops at the // end of this fn, so `coder` (and its `out` buffer) stay live // while `deliverAsync` copies. - let coder = unsafe { &*ctx.coder }; + let coder = unsafe { &*this.coder }; (coder.out.as_ptr(), coder.out.len(), JSValue::ZERO) } - Some(e) => (core::ptr::null(), 0, codec_error_to_js(global, &e)), + Some(e) => (core::ptr::null(), 0, codec_error_to_js(global, e)), }; // SAFETY: FFI into `JSCompressionStreamShared.cpp`; the callee copies // `out[..out_len]` before releasing the coder. - unsafe { Bun__CompressionStream__deliverAsync(global, stream, out, out_len, err) }; + unsafe { Bun__CompressionStream__deliverAsync(global, js.stream.get(), out, out_len, err) }; Ok(()) } } -/// Schedules one transform step on the WorkPool. The caller -/// (`codeAndEnqueue`) has already created the pending `JSPromise`, stored it -/// on `stream_cell->m_asyncCodecPromise`, and set `m_asyncCodecInFlight`; -/// `Bun__CompressionStream__deliverAsync` settles that promise. #[unsafe(no_mangle)] #[allow(clippy::not_unsafe_ptr_arg_deref)] pub extern "C" fn CompressionStreamCoder__transformAsync( @@ -884,17 +886,22 @@ pub extern "C" fn CompressionStreamCoder__transformAsync( // `fallback` is ignored) or copied into an owned Vec. unsafe { core::slice::from_raw_parts(input, input_len) } }; + let (input, pin) = AsyncInput::new(global, chunk, fallback); // SAFETY: `this` is the live coder owned by the calling JS cell; the ctx // takes its own reference (see `CompressionStreamCoder::ref_count`). unsafe { bun_ptr::ThreadSafeRefCount::::ref_(this) }; - let ctx = bun_core::heap::into_raw(Box::new(CompressionAsyncCtx { - coder: this, - input: AsyncInput::new(global, chunk, fallback), - finish, - stream: Strong::create(stream_cell, global), - error: None, - })); - let task = WorkTask::::create_on_js_thread(global, ctx); - // SAFETY: `task` is a freshly-allocated WorkTask; sole owner until scheduled. - WorkTask::schedule(unsafe { &mut *task }); + let cx = global.js_thread(); + bun_jsc::Job::::schedule( + &cx, + CompressionAsyncCtx { + coder: this, + input, + finish, + error: None, + }, + CompressionAsyncJs { + stream: Strong::create(stream_cell, global), + _pin: pin, + }, + ); } diff --git a/src/runtime/webcore/FileReader.rs b/src/runtime/webcore/FileReader.rs index 675393b58b00..afdb5643c021 100644 --- a/src/runtime/webcore/FileReader.rs +++ b/src/runtime/webcore/FileReader.rs @@ -802,7 +802,7 @@ impl FileReader { pending_buf[0..buffer.len()].copy_from_slice(&buffer); self.pending.with_mut(|p| { p.result = streams::Result::IntoArrayAndDone(streams::IntoArray { - value: self.pending_value.get().get().unwrap_or(JSValue::ZERO), + value: self.pending_value.get().get().unwrap_or_default(), len: buffer.len() as u64, // @truncate }) }); @@ -828,7 +828,7 @@ impl FileReader { self.buffered.with_mut(|b| b.clear()); let into_array = streams::IntoArray { - value: self.pending_value.get().get().unwrap_or(JSValue::ZERO), + value: self.pending_value.get().get().unwrap_or_default(), len: buf.len() as u64, // @truncate }; diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 1b0e09d8831a..fba7f7cc05c4 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -789,17 +789,10 @@ impl FileSink { self.run_pending_later.has.set(true); if let EventLoopHandle::Js { owner } = self.event_loop() { self.ref_(); - // The type→tag - // map lives in `crate::dispatch`; the resolved tag for - // `*FlushPendingTask` is `task_tag::FlushPendingFileSinkTask`. // Ptr identity only — `run_from_js_thread` recovers `*mut FileSink` // via `from_field_ptr!` and never forms `&mut FileSink`. - let task = bun_event_loop::Task::new( - bun_event_loop::task_tag::FlushPendingFileSinkTask, - core::ptr::from_ref(&self.run_pending_later) - .cast_mut() - .cast::<()>(), - ); + let task = + bun_event_loop::Task::init(core::ptr::from_ref(&self.run_pending_later).cast_mut()); owner.enqueue_task(task); } } @@ -1462,6 +1455,20 @@ pub struct FlushPendingTask { pub(crate) has: Cell, } +impl bun_event_loop::Taskable for FlushPendingTask { + const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::FlushPendingFileSinkTask; + /// The embedded "flush later" flag of a `FileSink` that took a ref for the + /// hop: clear it and drop that ref without flushing. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract; `this` is `FileSink.run_pending_later`. + unsafe { + (*this).has.set(false); + let sink: *mut FileSink = bun_core::from_field_ptr!(FileSink, run_pending_later, this); + drop(FileSinkRef::adopt(sink)); + } + } +} + impl FlushPendingTask { /// # Safety /// `flush_pending` must point to the `run_pending_later` field of a live diff --git a/src/runtime/webcore/Response.rs b/src/runtime/webcore/Response.rs index c77d927653dc..ddc5f9f78a02 100644 --- a/src/runtime/webcore/Response.rs +++ b/src/runtime/webcore/Response.rs @@ -1015,7 +1015,7 @@ impl Response { }, |r| r.body.get().reset(), ); - let json_value = args.next_eat().unwrap_or(JSValue::ZERO); + let json_value = args.next_eat().unwrap_or_default(); if !json_value.is_empty() { // Validate top-level values that are not JSON serializable (Node.js compatibility) @@ -1154,7 +1154,7 @@ impl Response { ..Default::default() }; - let url_string_value = args.next_eat().unwrap_or(JSValue::ZERO); + let url_string_value = args.next_eat().unwrap_or_default(); url_string = OwnedString::new(if url_string_value.is_empty() { BunString::empty() } else { diff --git a/src/runtime/webcore/TextDecoder.rs b/src/runtime/webcore/TextDecoder.rs index d95f808a1f83..869c63d5fa4f 100644 --- a/src/runtime/webcore/TextDecoder.rs +++ b/src/runtime/webcore/TextDecoder.rs @@ -2,6 +2,7 @@ use crate::webcore::EncodingLabel; use crate::webcore::jsc::{self as jsc, CallFrame, JSGlobalObject, JSValue, JsResult}; use bun_core::AllocError; use bun_core::{OwnedString, strings}; +use bun_jsc::HostReturn as _; use core::cell::Cell; use core::ptr::NonNull; @@ -756,5 +757,5 @@ pub extern "C" fn TextDecoder__decodeForStream( } else { this.decode_slice::(global, slice) }; - result.unwrap_or(JSValue::ZERO) + result.or_pending_exception() } diff --git a/src/runtime/webcore/TextEncoder.rs b/src/runtime/webcore/TextEncoder.rs index be4850af8000..a8c14102ab30 100644 --- a/src/runtime/webcore/TextEncoder.rs +++ b/src/runtime/webcore/TextEncoder.rs @@ -1,6 +1,7 @@ use core::ffi::c_void; use bun_core::strings; +use bun_jsc::HostReturn as _; use bun_jsc::js_string::Iterator as JSStringIterator; use bun_jsc::{ArrayBuffer, JSGlobalObject, JSString, JSType, JSValue, JsResult}; @@ -105,7 +106,7 @@ fn encode16_impl(global_this: &JSGlobalObject, slice: &[u16]) -> JSValue { let bytes = strings::to_utf8_alloc_with_type(slice); ArrayBuffer::from_bytes(bytes.leak(), JSType::Uint8Array) .to_js_unchecked(global_this) - .unwrap_or(JSValue::ZERO) + .or_pending_exception() } /// # Safety diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index cf4aae0668e7..90cf853a9b2e 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -29,7 +29,7 @@ use core::marker::ConstParamTy; // CopyFile (POSIX, blocking off-thread) // ─────────────────────────────────────────────────────────────────────────── -pub struct CopyFile<'a> { +pub struct CopyFile { #[cfg(not(windows))] pub(crate) destination_file_store: store::File, pub(crate) source_file_store: store::File, @@ -52,17 +52,12 @@ pub struct CopyFile<'a> { #[cfg(any(target_os = "linux", target_os = "android"))] pub(crate) read_off: SizeType, - // per LIFETIMES.tsv: JSC_BORROW → &JSGlobalObject - // TODO(refactor): lifetime — this struct is Box-allocated and crosses threads; - // `'a` here is unsound in practice. Likely should be *const JSGlobalObject. - pub global_this: &'a JSGlobalObject, - pub(crate) mkdirp_if_not_exists: bool, #[cfg(not(windows))] pub(crate) destination_mode: Option, } -impl MkdirpTarget for CopyFile<'_> { +impl MkdirpTarget for CopyFile { fn mkdirp_if_not_exists(&self) -> bool { self.mkdirp_if_not_exists } @@ -74,35 +69,48 @@ impl MkdirpTarget for CopyFile<'_> { } } -impl jsc::concurrent_promise_task::ConcurrentPromiseTaskContext for CopyFile<'_> { - const TASK_TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::CopyFilePromiseTask; - fn run(&mut self) { - self.run_async(); +// SAFETY: file stores/paths and blob store refs (atomic counts); nothing thread-affine. +unsafe impl Send for CopyFile {} + +impl jsc::JobContext for CopyFile { + type OffThread = Self; + type Js = jsc::JSPromiseStrong; + fn run( + this: &mut Self, + _vm: &jsc::vm_handle::Borrow, + done: bun_jsc::Completion, + ) -> Option> { + this.run_async(); + Some(done) } - fn then(&mut self, promise: &mut JSPromise) -> Result<(), jsc::JsTerminated> { - CopyFile::then(self, promise) + fn then( + mut this: Self, + mut promise: jsc::JSPromiseStrong, + cx: &jsc::JsThread<'_>, + ) -> jsc::JsResult<()> { + Ok(CopyFile::then(&mut this, promise.swap(), cx.global())?) } } -impl<'a> CopyFile<'a> { +impl CopyFile { + /// Schedule the copy on the work pool; returns its promise. #[cfg(not(windows))] pub(crate) fn create( store: StoreRef, source_store: StoreRef, off: SizeType, max_len: SizeType, - global_this: &'a JSGlobalObject, + global_this: &JSGlobalObject, mkdirp_if_not_exists: bool, destination_mode: Option, - ) -> Box> { - let read_file = Box::new(CopyFile { + ) -> JSValue { + let copy = CopyFile { destination_file_store: store.data.as_file().clone(), source_file_store: source_store.data.as_file().clone(), store: Some(store), source_store: Some(source_store), offset: off, max_length: max_len, - global_this, mkdirp_if_not_exists, destination_mode, // defaults: @@ -112,12 +120,19 @@ impl<'a> CopyFile<'a> { read_len: 0, #[cfg(any(target_os = "linux", target_os = "android"))] read_off: 0, - }); - CopyFilePromiseTask::create_on_js_thread(global_this, read_file) + }; + let cx = global_this.js_thread(); + let promise = jsc::JSPromiseStrong::init(global_this); + let value = promise.value(); + jsc::Job::::schedule(&cx, copy, promise); + value } - pub(crate) fn reject(&mut self, promise: &mut JSPromise) -> Result<(), jsc::JsTerminated> { - let global_this = self.global_this; + pub(crate) fn reject( + &mut self, + promise: &mut JSPromise, + global_this: &JSGlobalObject, + ) -> Result<(), jsc::JsTerminated> { let mut system_error: SystemError = self.system_error.take().unwrap_or_default(); if matches!( self.source_file_store.pathlike, @@ -133,22 +148,26 @@ impl<'a> CopyFile<'a> { } let instance = jsc::SystemError::from(system_error) - .to_error_instance_with_async_stack(self.global_this, promise); + .to_error_instance_with_async_stack(global_this, promise); if let Some(store) = self.store.take() { drop(store); // deref() } promise.reject(global_this, Ok(instance)) } - pub(crate) fn then(&mut self, promise: &mut JSPromise) -> Result<(), jsc::JsTerminated> { + pub(crate) fn then( + &mut self, + promise: &mut JSPromise, + global_this: &JSGlobalObject, + ) -> Result<(), jsc::JsTerminated> { drop(self.source_store.take()); // source_store.?.deref() if self.system_error.is_some() { - return self.reject(promise); + return self.reject(promise, global_this); } promise.resolve( - self.global_this, + global_this, JSValue::js_number_from_uint64(self.read_len as u64), ) } @@ -1065,6 +1084,8 @@ pub struct CopyFileWindows<'a> { // TODO(refactor): lifetime — heap-allocated and re-entered from libuv callbacks; // likely should be *const jsc::EventLoop. pub(crate) event_loop: &'a jsc::event_loop::EventLoop, + /// How the mkdirp pool completion gets back to the VM. + pub(crate) loop_handle: jsc::LoopHandle, pub(crate) size: SizeType, @@ -1339,7 +1360,7 @@ extern "C" fn on_write(req: *mut libuv::fs_t) { #[cfg(windows)] impl<'a> CopyFileWindows<'a> { pub(crate) fn on_read_write_loop_complete(&mut self) { - self.event_loop.unref_concurrently(); + self.event_loop.unref_keep_alive(); if let Some(err) = self.err.take() { self.throw(err); @@ -1370,6 +1391,7 @@ impl<'a> CopyFileWindows<'a> { promise: jsc::JSPromiseStrong::init(global), // SAFETY: all-zero is a valid libuv::fs_t io_request: bun_core::ffi::zeroed::(), + loop_handle: jsc::VirtualMachine::VirtualMachine::get().loop_handle(), event_loop, mkdirp_if_not_exists, destination_mode, @@ -1471,7 +1493,7 @@ impl<'a> CopyFileWindows<'a> { self.throw(err); } bun_sys::Result::Ok(()) => { - self.event_loop.ref_concurrently(); + self.event_loop.ref_keep_alive(); } } } @@ -1623,7 +1645,7 @@ impl<'a> CopyFileWindows<'a> { }); return; } - self.event_loop.ref_concurrently(); + self.event_loop.ref_keep_alive(); } pub fn throw(&mut self, err: bun_sys::Error) { @@ -1707,7 +1729,7 @@ impl<'a> CopyFileWindows<'a> { self.throw(err); return; } - self.event_loop.ref_concurrently(); + self.event_loop.ref_keep_alive(); return; } } @@ -1785,7 +1807,7 @@ impl<'a> CopyFileWindows<'a> { .unwrap_or(path_slice) as *const [u8] }; - self.event_loop.ref_concurrently(); + self.event_loop.ref_keep_alive(); node_fs::async_::AsyncMkdirp::schedule(node_fs::async_::AsyncMkdirp { completion: on_mkdirp_complete_concurrent, completion_ctx: core::ptr::from_mut(self).cast::<()>(), @@ -1795,7 +1817,7 @@ impl<'a> CopyFileWindows<'a> { } fn on_mkdirp_complete(&mut self) { - self.event_loop.unref_concurrently(); + self.event_loop.unref_keep_alive(); if let Some(err) = self.err.take() { // `bun_sys::Error.path` is an owned `Box<[u8]>` and is dropped with @@ -1816,7 +1838,7 @@ extern "C" fn on_copy_file(req: *mut libuv::fs_t) { debug_assert!(core::ptr::addr_of_mut!(this.io_request) == req); let event_loop = this.event_loop; - event_loop.unref_concurrently(); + event_loop.unref_keep_alive(); let rc = this.io_request.result; bun_sys::syslog!("uv_fs_copyfile() = {}", rc); @@ -1883,7 +1905,7 @@ extern "C" fn on_chmod(req: *mut libuv::fs_t) { debug_assert!(core::ptr::addr_of_mut!(this.io_request) == req); let event_loop = this.event_loop; - event_loop.unref_concurrently(); + event_loop.unref_keep_alive(); let rc = this.io_request.result; if let Some(errno) = rc.err_enum_e() { @@ -1918,10 +1940,15 @@ fn on_mkdirp_complete_concurrent(ctx: *mut (), err_: bun_sys::Maybe<()>) { unsafe { (*this).on_mkdirp_complete() }; Ok(()) } - this.event_loop - .enqueue_task_concurrent(jsc::ConcurrentTask::create( - jsc::ManagedTask::ManagedTask::new::(this, call_erased), - )); + let ct = jsc::ConcurrentTask::create(jsc::ManagedTask::ManagedTask::new::( + this, + call_erased, + )); + if let jsc::vm_handle::Posted::Refused(ct) = this.loop_handle.post_task(ct) { + // VM torn down: nobody will settle the promise; free the hop. + // SAFETY: refused ⇒ we own the task box. + unsafe { bun_event_loop::ConcurrentTask::ConcurrentTask::release_refused(ct) }; + } } // ─────────────────────────────────────────────────────────────────────────── @@ -1956,6 +1983,3 @@ fn unsupported_non_regular_file_error() -> SystemError { } // `SystemError` contains `bun_core::String`, which is not const-constructible, // so these are constructor fns instead of `const` values. - -pub(crate) type CopyFilePromiseTask<'a> = - jsc::concurrent_promise_task::ConcurrentPromiseTask<'a, CopyFile<'a>>; diff --git a/src/runtime/webcore/blob/read_file.rs b/src/runtime/webcore/blob/read_file.rs index 046a41ba48a8..591f77214fd0 100644 --- a/src/runtime/webcore/blob/read_file.rs +++ b/src/runtime/webcore/blob/read_file.rs @@ -157,22 +157,37 @@ pub enum ReadFileResultType { Err(SystemError), } -pub type ReadFileTask = bun_jsc::work_task::WorkTask; - -// `WorkTaskContext` fixes `run`/`then` to take `*mut Self`; the trait method -// cannot be marked `unsafe fn` and the parameter type cannot change, so the -// lint is unsatisfiable here. The pointers come from the work-pool hand-off -// and are guaranteed live (see SAFETY notes below). -#[allow(clippy::not_unsafe_ptr_arg_deref)] -impl bun_jsc::work_task::WorkTaskContext for ReadFile { - const TASK_TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::ReadFileTask; - fn run(this: *mut Self, task: *mut bun_jsc::work_task::WorkTask) { - // SAFETY: WorkTask::run_from_thread_pool guarantees `this` is live. - unsafe { (*this).run(task) } - } - fn then(this: *mut Self, global: &jsc::JSGlobalObject) -> Result<(), jsc::JsTerminated> { - // SAFETY: `this` was heap-allocated by the WorkTask flow; consumed here. - ReadFile::then(unsafe { bun_core::heap::take(this) }, global) +/// The completion token a `ReadFile` keeps across its async I/O. +pub type ReadFileTask = bun_jsc::Completion; + +// SAFETY: file store / byte store / blob store ref (atomic), the read buffer, +// io-loop registration state, and an opaque completion ctx that only the +// JS-thread completion dereferences — nothing used off-thread is thread-affine. +unsafe impl Send for ReadFile {} + +impl bun_jsc::JobContext for ReadFile { + type OffThread = Self; + /// The completion is delivered through `on_complete_callback(ctx, ..)`. + type Js = (); + fn run( + this: &mut Self, + _vm: &bun_jsc::vm_handle::Borrow, + done: bun_jsc::Completion, + ) -> Option> { + // Starts the read; finishes from the io loop via the token. + this.run(done); + None + } + fn then(this: Self, _: (), cx: &bun_jsc::JsThread<'_>) -> jsc::JsResult<()> { + Ok(ReadFile::then(this, cx.global())?) + } +} + +impl ReadFile { + /// JS thread: hand a prepared `ReadFile` to the work pool (the job is + /// its one heap allocation). + pub fn schedule(this: ReadFile, global: &JSGlobalObject) { + bun_jsc::Job::::schedule(&global.js_thread(), this, ()); } } @@ -204,7 +219,7 @@ pub struct ReadFile { pub(crate) on_complete_ctx: *mut c_void, pub(crate) on_complete_callback: ReadFileOnReadFileCallback, #[cfg(not(windows))] - pub(crate) io_task: Option<*mut ReadFileTask>, + pub(crate) io_task: Option, pub(crate) io_poll: io::Poll, pub(crate) io_request: io::Request, #[cfg(not(windows))] @@ -347,10 +362,10 @@ impl ReadFile { on_complete_callback: ReadFileOnReadFileCallback, off: SizeType, max_len: SizeType, - ) -> Result, Error> { + ) -> Result { // store.ref() — `StoreRef` carries the +1; held in `self.store`. let file_store = store.data.as_file().clone(); - let read_file = Box::new(ReadFile { + let read_file = ReadFile { file_store, byte_store: ByteStore::default(), store: Some(store), @@ -380,7 +395,7 @@ impl ReadFile { could_block: false, close_after_io: false, state: AtomicU8::new(ClosingState::Running as u8), - }); + }; Ok(read_file) } @@ -390,7 +405,7 @@ impl ReadFile { off: SizeType, max_len: SizeType, context: *mut C, - ) -> Result, Error> { + ) -> Result { // `ReadFileCompletion` // monomorphizes per `C`, so `handler_run::` calls `C::run` directly // and `on_complete_ctx` is the unwrapped `*mut C` — no extra heap box, @@ -577,7 +592,7 @@ impl ReadFile { true } - pub(crate) fn then(this: Box, _: &JSGlobalObject) -> jsc::JsTerminatedResult<()> { + pub(crate) fn then(this: Self, _: &JSGlobalObject) -> jsc::JsTerminatedResult<()> { let cb = this.on_complete_callback; let cb_ctx = this.on_complete_ctx; @@ -630,15 +645,16 @@ impl ReadFile { Ok(()) } - pub(crate) fn run(&mut self, task: *mut ReadFileTask) { + pub(crate) fn run(&mut self, task: ReadFileTask) { self.run_async(task); } - fn run_async(&mut self, task: *mut ReadFileTask) { + fn run_async(&mut self, task: ReadFileTask) { #[cfg(windows)] { + // Windows reads go through ReadFileUV, never the pool. let _ = task; - return; // why + unreachable!("ReadFile on the work pool (Windows uses ReadFileUV)"); } #[cfg(not(windows))] { @@ -672,8 +688,7 @@ impl ReadFile { if !close_after_io { if let Some(io_task) = self.io_task.take() { bloblog!("ReadFile.onFinish() = immediately"); - // SAFETY: io_task is a non-null backref set in run(); WorkTask owns lifetime. - ReadFileTask::on_finish(unsafe { &mut *io_task }); + io_task.finish(); } } } @@ -1089,7 +1104,7 @@ impl<'a> ReadFileUV<'a> { log!("ReadFileUV.start"); // SAFETY: `event_loop` is the per-thread `EventLoop` singleton owned by // the VM (`global.bun_vm().event_loop()`); it strictly outlives this - // async op, which additionally pins it via `ref_concurrently()` below. + // async op, which additionally holds a keep-alive on it below. let event_loop: &'a EventLoop = unsafe { &*event_loop }; let file_store = store.data.as_file().clone(); let this = Box::new(ReadFileUV { @@ -1118,7 +1133,7 @@ impl<'a> ReadFileUV<'a> { open_callback: Self::on_file_open, }); // Keep the event loop alive while the async operation is pending - event_loop.ref_concurrently(); + event_loop.ref_keep_alive(); let this_ptr: *mut ReadFileUV = bun_core::heap::into_raw(this); // SAFETY: this_ptr is freshly boxed and uniquely owned by the async op. unsafe { (*this_ptr).get_fd(Self::on_file_open) }; @@ -1157,7 +1172,7 @@ impl<'a> ReadFileUV<'a> { this_box.req.deinit(); drop(this_box); // Release the event loop reference now that we're done - event_loop.unref_concurrently(); + event_loop.unref_keep_alive(); log!("ReadFileUV.finalize destroy"); } diff --git a/src/runtime/webcore/blob/write_file.rs b/src/runtime/webcore/blob/write_file.rs index 32a0e2762a8c..f83b02a1e764 100644 --- a/src/runtime/webcore/blob/write_file.rs +++ b/src/runtime/webcore/blob/write_file.rs @@ -34,22 +34,37 @@ pub enum WriteFileResultType { pub type WriteFileOnWriteFileCallback = fn(ctx: *mut c_void, count: WriteFileResultType) -> Result<(), JsTerminated>; -pub type WriteFileTask = bun_jsc::work_task::WorkTask; - -// `WorkTaskContext` fixes `run`/`then` to take `*mut Self`; the trait method -// cannot be marked `unsafe fn` and the parameter type cannot change, so the -// lint is unsatisfiable here. The pointers come from the work-pool hand-off -// and are guaranteed live (see SAFETY notes below). -#[allow(clippy::not_unsafe_ptr_arg_deref)] -impl bun_jsc::work_task::WorkTaskContext for WriteFile { - const TASK_TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::WriteFileTask; - fn run(this: *mut Self, task: *mut bun_jsc::work_task::WorkTask) { - // SAFETY: WorkTask::run_from_thread_pool guarantees `this` is live. - unsafe { (*this).run(task) } +/// The completion token a `WriteFile` keeps across its async I/O. +pub type WriteFileTask = bun_jsc::Completion; + +// SAFETY: the two blobs are native values holding store refs (atomic counts); +// io-loop registration state and an opaque completion ctx that only the +// JS-thread completion dereferences — nothing used off-thread is thread-affine. +unsafe impl Send for WriteFile {} + +impl bun_jsc::JobContext for WriteFile { + type OffThread = Self; + /// The completion is delivered through `on_complete_callback(ctx, ..)`. + type Js = (); + fn run( + this: &mut Self, + _vm: &bun_jsc::vm_handle::Borrow, + done: bun_jsc::Completion, + ) -> Option> { + // Starts the write; finishes from the io loop via the token. + this.run(done); + None } - fn then(this: *mut Self, global: &jsc::JSGlobalObject) -> Result<(), JsTerminated> { - // SAFETY: `this` was heap-allocated by the WorkTask flow; consumed here. - WriteFile::then(unsafe { bun_core::heap::take(this) }, global) + fn then(this: Self, _: (), cx: &bun_jsc::JsThread<'_>) -> jsc::JsResult<()> { + Ok(WriteFile::then(this, cx.global())?) + } +} + +impl WriteFile { + /// JS thread: hand a prepared `WriteFile` to the work pool (the job is + /// its one heap allocation). + pub fn schedule(this: WriteFile, global: &JSGlobalObject) { + bun_jsc::Job::::schedule(&global.js_thread(), this, ()); } } @@ -63,7 +78,7 @@ pub struct WriteFile { pub(crate) errno: Option, pub task: WorkPoolTask, #[cfg(not(windows))] - pub(crate) io_task: Option<*mut WriteFileTask>, + pub(crate) io_task: Option, pub(crate) io_poll: io::Poll, pub(crate) io_request: io::Request, pub(crate) state: AtomicU8, // ClosingState @@ -284,8 +299,8 @@ impl WriteFile { on_write_file_context: *mut c_void, on_complete_callback: WriteFileOnWriteFileCallback, mkdirp_if_not_exists: bool, - ) -> Result<*mut WriteFile, Error> { - let write_file = bun_core::heap::into_raw(Box::new(WriteFile { + ) -> Result { + let write_file = WriteFile { file_blob, bytes_blob, opened_fd: Fd::INVALID, @@ -305,11 +320,10 @@ impl WriteFile { could_block: false, close_after_io: false, mkdirp_if_not_exists, - })); + }; // No explicit store ref bump: the caller passes a `+1` Blob (via - // `borrowed_view()`'s `StoreRef::clone`) and `heap::take(this)` in - // `then` runs `StoreRef::drop`, so the ref/deref pair is - // folded into RAII. + // `borrowed_view()`'s `StoreRef::clone`) and dropping the `WriteFile` + // in `then` runs `StoreRef::drop`, so the ref/deref pair is RAII. Ok(write_file) } @@ -320,7 +334,7 @@ impl WriteFile { context: *mut C, callback: WriteFileOnWriteFileCallback, mkdirp_if_not_exists: bool, - ) -> Result<*mut WriteFile, Error> { + ) -> Result { // The caller supplies a // `*mut c_void`-typed callback directly (see `WriteFilePromise::run`), // so this is just a `.cast()` on `context`. @@ -376,10 +390,7 @@ impl WriteFile { true } - pub(crate) fn then( - mut this: Box, - _global: &JSGlobalObject, - ) -> Result<(), JsTerminated> { + pub(crate) fn then(mut this: WriteFile, _global: &JSGlobalObject) -> Result<(), JsTerminated> { let cb = this.on_complete_callback; let cb_ctx = this.on_complete_ctx; let system_error = this.system_error.take(); @@ -405,11 +416,12 @@ impl WriteFile { Ok(()) } - pub(crate) fn run(&mut self, task: *mut WriteFileTask) { + pub(crate) fn run(&mut self, task: WriteFileTask) { #[cfg(windows)] { + // Windows writes go through WriteFileWindows, never the pool. let _ = task; - panic!("todo"); + unreachable!("WriteFile on the work pool (Windows uses WriteFileWindows)"); } #[cfg(not(windows))] { @@ -446,8 +458,7 @@ impl WriteFile { } if !close_after_io { if let Some(io_task) = self.io_task.take() { - // SAFETY: io_task is a backref set in run(); WorkTask owns lifetime. - bun_jsc::work_task::WorkTask::on_finish(unsafe { &mut *io_task }); + io_task.finish(); } } } @@ -633,6 +644,8 @@ mod windows_impl { pub(crate) err: Option, pub(crate) total_written: usize, pub(crate) event_loop: *mut EventLoop, + /// How the mkdirp pool completion gets back to the VM. + pub(crate) loop_handle: bun_jsc::LoopHandle, pub poll_ref: KeepAlive, pub(crate) owned_fd: bool, @@ -690,6 +703,7 @@ mod windows_impl { base: null_mut(), len: 0, }], + loop_handle: bun_jsc::virtual_machine::VirtualMachine::get().loop_handle(), event_loop, fd: -1, err: None, @@ -1025,11 +1039,16 @@ mod windows_impl { bun_sys::Result::Err(e) => Some(e), bun_sys::Result::Ok(()) => None, }; - // SAFETY: event_loop is the VM-owned EventLoop with process lifetime. - unsafe { - (*this.event_loop).enqueue_task_concurrent(ConcurrentTask::create( - ManagedTask::new::(this, Self::on_mkdirp_complete_task), - )); + let ct = ConcurrentTask::create(ManagedTask::new::( + this, + Self::on_mkdirp_complete_task, + )); + if let bun_jsc::vm_handle::Posted::Refused(ct) = this.loop_handle.post_task(ct) { + // VM torn down: nobody will settle the promise. Free the hop (the + // ConcurrentTask owns the boxed ManagedTask); the operation's + // buffers/fd go with the process's teardown of its owner. + // SAFETY: refused ⇒ we own the task box. + unsafe { bun_event_loop::ConcurrentTask::ConcurrentTask::release_refused(ct) }; } } diff --git a/src/runtime/webcore/encoding.rs b/src/runtime/webcore/encoding.rs index ac19fe4f3ab8..d7c2fb127210 100644 --- a/src/runtime/webcore/encoding.rs +++ b/src/runtime/webcore/encoding.rs @@ -181,7 +181,7 @@ unsafe extern "C" fn Bun__encoding__constructFromLatin1( Encoding::Latin1 | Encoding::Buffer => unreachable!(), }, |E| construct_from_u8::(input, len)) }); - JSValue::create_buffer(global_object, &mut slice[..]) + bun_jsc::HostReturn::or_pending_exception(JSValue::create_buffer(global_object, &mut slice[..])) } /// # Safety @@ -204,7 +204,7 @@ unsafe extern "C" fn Bun__encoding__constructFromUTF16( Encoding::Buffer => unreachable!(), }, |E| construct_from_u16::(input, len)) }); - JSValue::create_buffer(global_object, &mut slice[..]) + bun_jsc::HostReturn::or_pending_exception(JSValue::create_buffer(global_object, &mut slice[..])) } // for SQL statement diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 82d13815de41..2a41b66e2cc4 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -82,8 +82,8 @@ use bun_s3_signing::{SignOptions, SignResult}; use bun_url::PercentEncoding; use bun_url::URL as ZigURL; -pub use self::fetch_tasklet::FetchTasklet; use self::fetch_tasklet::{FetchOptions, HTTPRequestBody}; +pub use self::fetch_tasklet::{FetchTasklet, FetchTaskletDeinitHop}; // ────────────────────────────────────────────────────────────────────────── // Local extension shims (upstream methods not yet ported / not in scope) @@ -643,8 +643,8 @@ fn fetch_impl( // "decompress: boolean" disable_decompression = 'extract_disable_decompression: { let objects_to_try = [ - options_object.unwrap_or(JSValue::ZERO), - request_init_object.unwrap_or(JSValue::ZERO), + options_object.unwrap_or_default(), + request_init_object.unwrap_or_default(), ]; for obj in objects_to_try { @@ -673,8 +673,8 @@ fn fetch_impl( // "compress: boolean | string | { encoding, level? }" 'extract_compress: { let objects_to_try = [ - options_object.unwrap_or(JSValue::ZERO), - request_init_object.unwrap_or(JSValue::ZERO), + options_object.unwrap_or_default(), + request_init_object.unwrap_or_default(), ]; for obj in objects_to_try { @@ -700,8 +700,8 @@ fn fetch_impl( // "maxRedirects: number" 'extract_max_redirects: { let objects_to_try = [ - options_object.unwrap_or(JSValue::ZERO), - request_init_object.unwrap_or(JSValue::ZERO), + options_object.unwrap_or_default(), + request_init_object.unwrap_or_default(), ]; for obj in objects_to_try { @@ -738,8 +738,8 @@ fn fetch_impl( // "tls: TLSConfig" ssl_config = 'extract_ssl_config: { let objects_to_try = [ - options_object.unwrap_or(JSValue::ZERO), - request_init_object.unwrap_or(JSValue::ZERO), + options_object.unwrap_or_default(), + request_init_object.unwrap_or_default(), ]; for obj in objects_to_try { @@ -795,8 +795,8 @@ fn fetch_impl( // unix: string | undefined unix_socket_path = 'extract_unix_socket_path: { let objects_to_try = [ - options_object.unwrap_or(JSValue::ZERO), - request_init_object.unwrap_or(JSValue::ZERO), + options_object.unwrap_or_default(), + request_init_object.unwrap_or_default(), ]; for obj in objects_to_try { @@ -822,8 +822,8 @@ fn fetch_impl( // protocol: "http2" | "h2" | "http1.1" | "h1" | undefined. 'extract_protocol: { let objects_to_try = [ - options_object.unwrap_or(JSValue::ZERO), - request_init_object.unwrap_or(JSValue::ZERO), + options_object.unwrap_or_default(), + request_init_object.unwrap_or_default(), ]; for obj in objects_to_try { if !obj.is_empty() { @@ -852,8 +852,8 @@ fn fetch_impl( // timeout: false | number | undefined disable_timeout = 'extract_disable_timeout: { let objects_to_try = [ - options_object.unwrap_or(JSValue::ZERO), - request_init_object.unwrap_or(JSValue::ZERO), + options_object.unwrap_or_default(), + request_init_object.unwrap_or_default(), ]; for obj in objects_to_try { @@ -903,8 +903,8 @@ fn fetch_impl( // Then check options/init objects which can override the Request's redirect let objects_to_try = [ - options_object.unwrap_or(JSValue::ZERO), - request_init_object.unwrap_or(JSValue::ZERO), + options_object.unwrap_or_default(), + request_init_object.unwrap_or_default(), ]; for obj in objects_to_try { @@ -931,8 +931,8 @@ fn fetch_impl( // keepalive: boolean | undefined; disable_keepalive = 'extract_disable_keepalive: { let objects_to_try = [ - options_object.unwrap_or(JSValue::ZERO), - request_init_object.unwrap_or(JSValue::ZERO), + options_object.unwrap_or_default(), + request_init_object.unwrap_or_default(), ]; for obj in objects_to_try { @@ -961,8 +961,8 @@ fn fetch_impl( // verbose: boolean | "curl" | undefined; verbose = 'extract_verbose: { let objects_to_try = [ - options_object.unwrap_or(JSValue::ZERO), - request_init_object.unwrap_or(JSValue::ZERO), + options_object.unwrap_or_default(), + request_init_object.unwrap_or_default(), ]; for obj in objects_to_try { @@ -994,8 +994,8 @@ fn fetch_impl( // `defer if (proxy_headers) |*hdrs| hdrs.deinit();` → Headers impls Drop. url_proxy_buffer = 'extract_proxy: { let objects_to_try = [ - options_object.unwrap_or(JSValue::ZERO), - request_init_object.unwrap_or(JSValue::ZERO), + options_object.unwrap_or_default(), + request_init_object.unwrap_or_default(), ]; for obj in objects_to_try { if !obj.is_empty() { diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index f477af732475..f685dba84622 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -44,8 +44,36 @@ use boringssl::c::{X509_free, d2i_X509}; // ConcurrentTask::from() needs `Taskable`; tag is declared in bun_event_loop // but the impl lives next to the type (cycle-break). +/// The "last ref dropped on the HTTP thread → deinit on the JS thread" hop: +/// same pointer, its own tag, so teardown can tell it from a progress update. +#[repr(transparent)] +pub struct FetchTaskletDeinitHop(FetchTasklet); +impl Taskable for FetchTaskletDeinitHop { + const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::FetchTaskletDeinit; + /// The last ref dropped on the HTTP thread while we were tearing down: + /// deinit here, on the JS thread with the heap alive, as the hop intended. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract. + unsafe { Self::run(this) } + } +} +impl FetchTaskletDeinitHop { + /// # Safety + /// `this` is the tasklet the hop was created from, ref_count == 0, JS thread. + pub(crate) unsafe fn run(this: *mut Self) { + // SAFETY: fn contract. + unsafe { FetchTasklet::deinit(this.cast()) } + } +} + impl Taskable for FetchTasklet { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::FetchTasklet; + /// A progress hop the HTTP thread posted: it carries the +1 that + /// `on_progress_update` would have dropped. The HTTP thread is parked / + /// this VM's requests are back, so a 1→0 here deinits against a live heap. + unsafe fn release_unrun(this: *mut Self) { + FetchTasklet::deref(this); + } } bun_output::declare_scope!(FetchTasklet, visible); @@ -67,7 +95,9 @@ pub struct FetchTasklet { pub(crate) http: Option>>, pub(crate) result: HTTPClientResult<'static>, pub(crate) metadata: Option, - pub(crate) javascript_vm: &'static VirtualMachine, + /// How the HTTP thread reaches the VM (post progress/deinit tasks). JS-thread + /// code uses the VM through `global_this` instead. + pub(crate) loop_handle: jsc::LoopHandle, pub global_this: GlobalRef, pub(crate) request_body: HTTPRequestBody, // ThreadSafeStreamBuffer is intrusively refcounted (`ref_count: AtomicU32`, @@ -299,17 +329,11 @@ impl FetchTasklet { unsafe { &*this } } - /// Enqueue a concurrent task on the JS-thread event loop. - /// - /// Centralises the `(*vm.event_loop()).enqueue_task_concurrent(..)` raw - /// deref. `event_loop()` returns a self-ptr into the VirtualMachine that - /// is valid for the VM's lifetime; `enqueue_task_concurrent` takes `&self` - /// and is thread-safe (lock-free MPSC push). `task` is a live - /// `ConcurrentTaskItem` that the queue takes ownership of via its - /// intrusive `next` link. + /// HTTP thread → JS thread: queue `task` on the tasklet's VM. Returns the + /// task back if the VM has been torn down (caller releases what it holds). #[inline] - fn enqueue_concurrent(vm: &VirtualMachine, task: core::ptr::NonNull) { - vm.event_loop_shared().enqueue_task_concurrent(task); + fn post(&self, task: core::ptr::NonNull) -> jsc::vm_handle::Posted { + self.loop_handle.post_task(task) } /// Wrap a borrowed body chunk in a `StreamResult::Temporary*` for @@ -397,31 +421,17 @@ impl FetchTasklet { return; } let self_ = Self::from_raw_ref(this); - if self_.javascript_vm.is_shutting_down() { - // SAFETY: last ref; exclusive access. `deinit()` would run - // `clear_data()` + `Drop` for the JSC `Strong`/`Weak` fields, which - // reach into the VM's StrongRootBlock list / WeakSet from this - // (HTTP) thread — not thread-safe. Reclaim only the Rust-side - // boxes; those structures are freed wholesale by `destructOnExit`. - unsafe { FetchTasklet::dealloc_for_shutdown(this) }; - return; - } - // 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. - Self::enqueue_concurrent( - self_.javascript_vm, - ConcurrentTask::from_callback(this, FetchTasklet::deinit_callback), - ); - } - - // ConcurrentTask::from_callback takes `fn(*mut T) -> bun_event_loop::JsResult<()>` - // (cycle-broken erased error). - fn deinit_callback(this: *mut FetchTasklet) -> ElJsResult<()> { - // SAFETY: enqueued with last ref; exclusive access on main thread - unsafe { FetchTasklet::deinit(this) }; - Ok(()) + // Last ref dropped on the HTTP thread: deinit must run on the JS thread + // (it drops JSC Strong/Weak handles), so hop there — as a task with its + // own tag, so a VM that is tearing down releases it from its queue. The + // VM waits for its fetches (embedded work) before closing its handle, + // so this is always queued. + let task = ConcurrentTask::create(bun_event_loop::Task::init( + this.cast::(), + )); + let jsc::vm_handle::Posted::Queued = self_.post(task) else { + unreachable!("VM handle closed with a fetch outstanding on the HTTP thread"); + }; } fn clear_sink(&mut self) { @@ -501,6 +511,8 @@ impl FetchTasklet { // SAFETY: caller contract — `this` is live with ref_count == 0. unsafe { (*this).ref_count.assert_no_refs() }; + // JS thread: no longer something the VM must abort at teardown. + crate::jsc_hooks::ActiveHandle::Fetch(NonNull::new(this).expect("tasklet")).unregister(); // SAFETY: this was allocated via heap::alloc in `get()`; ref_count == 0 so exclusive let mut boxed = unsafe { bun_core::heap::take(this) }; @@ -509,34 +521,16 @@ impl FetchTasklet { drop(boxed); } - /// Last-ref reclaim from the HTTP thread once the VM has begun shutdown. - /// - /// Neither `clear_data()` nor dropping the box is safe here: - /// * the JSC `Strong`/`Weak` fields touch the VM's StrongRootBlock list / - /// WeakSet on `Drop` (JS-thread-only), and - /// * the leaked `response: jsc::Weak` keeps a finalize callback - /// (`on_response_finalize`) registered against `this`, so freeing the - /// box before `destructOnExit` sweeps the Response is a UAF. + /// VM teardown's stop phase (JS thread): abort the transport. The HTTP + /// thread then fails the request promptly — started or still queued — and + /// hands the tasklet back through its final callback, which teardown + /// waits for before the handle closes. /// - /// Park the intact box on the JS thread via - /// `bun_http::defer_shutdown_reclaim`; the drain runs from - /// `global_exit()` after the HTTP thread has parked but before - /// `destructOnExit`, so `deinit()` there can release every handle on the - /// right thread and the Weak is cleared before its referent is finalized. - /// - /// SAFETY: `this` must be the last reference (ref_count == 0) and have - /// been allocated via heap::alloc. - unsafe fn dealloc_for_shutdown(this: *mut FetchTasklet) { - bun_output::scoped_log!(FetchTasklet, "deallocForShutdown"); - // SAFETY: caller contract — `this` is live with ref_count == 0. - unsafe { (*this).ref_count.assert_no_refs() }; - http::defer_shutdown_reclaim(this.cast(), FetchTasklet::deinit_erased); - } - - unsafe fn deinit_erased(this: *mut c_void) { - // SAFETY: parked by `dealloc_for_shutdown` with ref_count == 0; runs - // on the JS thread after the HTTP daemon has parked. - unsafe { FetchTasklet::deinit(this.cast()) }; + /// # Safety + /// `this` is live (registered ⇒ not yet deinit'd); JS thread. + pub(crate) unsafe fn stop_for_vm_teardown(this: *mut FetchTasklet) { + // SAFETY: fn contract. + unsafe { (*this).abort_task() }; } /// `HTTPClientResultCallback::release_at_shutdown` for `FetchTasklet`. @@ -548,12 +542,12 @@ impl FetchTasklet { /// is already parked in the parent's concurrent queue. /// /// The `has_schedule_callback` flag distinguishes the two states: - /// * `false` — nothing queued. Drop both refs here; `dealloc_for_shutdown` - /// parks the box for `shutdown_for_exit`'s drain. + /// * `false` — nothing queued. Drop both refs here; the last one hops + /// `deinit` to the JS thread, which teardown runs from its queue release. /// * `true` — a non-final `on_progress_update` is queued (this entry is /// still in `in_flight`, so the *final* `callback` hasn't run). That /// queued node owns the JS-side ref. The JS thread releases it from - /// `release_queued_tasks_for_shutdown` *after* the HTTP daemon parks; + /// `release_queued_tasks` *after* the HTTP daemon parks; /// dropping it here too would leave the queued node pointing at a /// freed `FetchTasklet`. Drop only the HTTP-side ref. /// @@ -571,13 +565,18 @@ impl FetchTasklet { let queued_progress_update = unsafe { (*this).has_schedule_callback.load(Ordering::Acquire) }; // SAFETY: caller contract — `this` is live and HTTP-thread-exclusive. - unsafe { (*this).scheduled_response_buffer = MutableString::default() }; + let handle = unsafe { + (*this).scheduled_response_buffer = MutableString::default(); + (*this).loop_handle.clone() + }; // SAFETY: caller contract — `this` is live and HTTP-thread-exclusive. FetchTasklet::deref_from_thread(this); if !queued_progress_update { // SAFETY: caller contract — `this` is live and HTTP-thread-exclusive. FetchTasklet::deref_from_thread(this); } + // The HTTP thread is done with this fetch. + handle.embedded_work_finished(); } fn get_current_response(&self) -> Option<*mut Response> { @@ -921,9 +920,9 @@ impl FetchTasklet { self.has_schedule_callback.store(false, Ordering::Relaxed); let is_done = !self.result.has_more; - let vm = self.javascript_vm; - // vm is shutting down we cannot touch JS - if vm.is_shutting_down() { + let vm = self.global_this.bun_vm(); + // teardown forbade script: we cannot touch JS + if !vm.script_allowed() { // The certificate will never be checked; release the parked // HTTP-thread socket instead of leaving it occupying an active // request slot until the idle timeout. @@ -1854,7 +1853,6 @@ impl FetchTasklet { } } // we should not keep the process alive if we are ignoring the body - let _ = self.javascript_vm; self.poll_ref.unref(bun_io::js_vm_ctx()); // When reached from `on_response_finalize` (a JSC Weak finalizer inside // `WeakBlock::sweep`), `clear_stream_handlers()` must be skipped: it @@ -1923,7 +1921,7 @@ impl FetchTasklet { http: None, result: HTTPClientResult::default(), metadata: None, - javascript_vm: jsc_vm, + loop_handle: jsc_vm.loop_handle(), global_this: GlobalRef::from(global_this), request_body: fetch_options.body, request_body_streaming_buffer: None, @@ -2168,17 +2166,14 @@ impl FetchTasklet { /// This is ALWAYS called from the http thread and we cannot touch the buffer here because is locked fn on_write_request_data_drain(this: *mut FetchTasklet) { let this_ref = Self::from_raw_ref(this); - if this_ref.javascript_vm.is_shutting_down() { - return; - } // ref until the main thread callback is called this_ref.ref_(); - // `from_callback` heap-allocates a fresh `ConcurrentTaskItem`; the queue - // takes ownership of it. - Self::enqueue_concurrent( - this_ref.javascript_vm, - ConcurrentTask::from_callback(this, FetchTasklet::resume_request_data_stream), - ); + // `from_callback` heap-allocates a fresh `ConcurrentTaskItem`. + let task = ConcurrentTask::from_callback(this, FetchTasklet::resume_request_data_stream); + // In flight ⇒ still counted work of its VM: the handle is open. + let jsc::vm_handle::Posted::Queued = this_ref.post(task) else { + unreachable!("VM handle closed with a fetch outstanding on the HTTP thread"); + }; } /// This is ALWAYS called from the main thread @@ -2394,6 +2389,10 @@ impl FetchTasklet { // increment ref so we can keep it alive until the http client is done node_ref.ref_(); + // Out on the HTTP thread from here until its final callback: the VM + // aborts it at teardown (registry) and waits for it (embedded work). + node_ref.loop_handle.embedded_work_scheduled(); + crate::jsc_hooks::ActiveHandle::Fetch(NonNull::new(node).expect("tasklet")).register(); http::HTTPThread::schedule(batch); Ok(node) @@ -2416,6 +2415,9 @@ impl FetchTasklet { // at this point only this thread is accessing result to is no race condition let is_done = !result.has_more; let task_ref = Self::from_raw_mut(task); + // The final callback is where the HTTP thread hands the fetch back + // (`embedded_work_finished` below, after our deref may have freed it). + let done_handle = is_done.then(|| task_ref.loop_handle.clone()); task_ref.mutex.lock(); // we need to unlock before task.deref(); @@ -2486,9 +2488,10 @@ impl FetchTasklet { if success && task_ref.result.has_more { // we are ignoring the body so we should not receive more data, so will only signal when result.has_more = true task_ref.mutex.unlock(); - if is_done { + if let Some(handle) = done_handle { // SAFETY: `task` is the live heap tasklet; HTTP-thread ref held. FetchTasklet::deref_from_thread(task); + handle.embedded_work_finished(); } return; } @@ -2538,61 +2541,34 @@ impl FetchTasklet { ) { if has_schedule_callback { task_ref.mutex.unlock(); - if is_done { + if let Some(handle) = done_handle { // SAFETY: `task` is the live heap tasklet; HTTP-thread ref held. FetchTasklet::deref_from_thread(task); + handle.embedded_work_finished(); } return; } } // will deinit when done with the http client (when is_done = true) - if task_ref.javascript_vm.is_shutting_down() { - // VM teardown: the JS-thread side will never drain this buffer (its - // on_progress_update bails the same way), so free the body bytes now. - task_ref.scheduled_response_buffer = MutableString::default(); - // The certificate will never be checked; release the parked - // socket instead of leaving it occupying an active request slot - // until the idle timeout. - if task_ref.result.certificate_info.take().is_some() { - if let Some(http_) = task_ref.http.as_mut() { - http::http_thread().schedule_shutdown(http_); - } - } - // We won the `has_schedule_callback` CAS above but are not - // enqueueing the on_progress_update task; undo the flag so a later - // (final) callback can re-enter this branch instead of taking the - // already-scheduled early return. - task_ref - .has_schedule_callback - .store(false, Ordering::Release); - task_ref.mutex.unlock(); - if is_done { - // No on_progress_update will ever run for this final result, so - // release the JS-side ref it would have dropped, then the - // HTTP-side ref. The 1→0 transition runs `dealloc_for_shutdown` - // (Rust boxes only — JSC handles are leaked to destructOnExit). - // SAFETY: `task` is the live heap tasklet; both refs held. - FetchTasklet::deref_from_thread(task); - // SAFETY: second ref still held until this 1→0 transition. - FetchTasklet::deref_from_thread(task); - } - return; - } let ct = core::ptr::NonNull::from( task_ref .concurrent_task .from(task, AutoDeinit::ManualDeinit), ); // `ct` is the inline `concurrent_task` field of the heap tasklet; the - // queue takes ownership of its `next` link. - Self::enqueue_concurrent(task_ref.javascript_vm, ct); + // queue takes ownership of its `next` link. The VM waits for its + // fetches (embedded work) before closing its handle: always queued. + let jsc::vm_handle::Posted::Queued = task_ref.post(ct) else { + unreachable!("VM handle closed with a fetch outstanding on the HTTP thread"); + }; task_ref.mutex.unlock(); // we are done with the http client so we can deref our side // this is a atomic operation and will enqueue a task to deinit on the main thread - if is_done { + if let Some(handle) = done_handle { // SAFETY: `task` is the live heap tasklet; HTTP-thread ref held. FetchTasklet::deref_from_thread(task); + handle.embedded_work_finished(); } } } @@ -2796,4 +2772,9 @@ impl FetchTaskletPromiseSettle { impl bun_event_loop::Taskable for FetchTaskletPromiseSettle { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::FetchTaskletPromiseSettle; + /// Drop the held value and promise handle without settling. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — the box the completion queued. + drop(unsafe { bun_core::heap::take(this) }); + } } diff --git a/src/runtime/webcore/s3/client.rs b/src/runtime/webcore/s3/client.rs index 74d062c5d95a..329617c105d6 100644 --- a/src/runtime/webcore/s3/client.rs +++ b/src/runtime/webcore/s3/client.rs @@ -301,13 +301,14 @@ pub(crate) fn list_objects( callback_context, callback: s3_simple_request::Callback::ListObjects(callback), headers, - vm: Some(bun_ptr::BackRef::new(VirtualMachine::get())), + loop_handle: VirtualMachine::get().loop_handle(), response_buffer: MutableString::default(), result: bun_http::HTTPClientResult::default(), concurrent_task: Default::default(), proxy_url: Box::default(), body: Box::default(), poll_ref: bun_io::KeepAlive::init(), + signal_store: Default::default(), })); // SAFETY: just allocated, non-null let task = unsafe { &mut *task_ptr }; @@ -339,7 +340,8 @@ pub(crate) fn list_objects( } else { None }; - let vm = task.vm.expect("vm set at task creation"); + // JS thread (request setup): read options from the current VM. + let vm = VirtualMachine::get(); task.http.write(bun_http::AsyncHTTP::init( bun_http::Method::GET, @@ -347,17 +349,19 @@ pub(crate) fn list_objects( task.headers.entries.clone().expect("OOM"), headers_buf, b"", - bun_http::HTTPClientResultCallback::new::( + bun_http::HTTPClientResultCallback::new_with_release::( task_ptr, // SAFETY: `task_ptr` is the heap-allocated task registered above; the // HTTP thread invokes this with that exact pointer. S3HttpSimpleTask::http_callback, + S3HttpSimpleTask::release_at_shutdown, ), bun_http::FetchRedirect::Follow, bun_http::async_http::Options { http_proxy, verbose: Some(vm.get_verbose_fetch()), reject_unauthorized: Some(vm.get_tls_reject_unauthorized()), + signals: Some(task.signal_store.to()), ..Default::default() }, )); @@ -367,6 +371,11 @@ pub(crate) fn list_objects( let mut batch = bun_threading::thread_pool::Batch::default(); // SAFETY: `http` was initialised by `task.http.write(...)` immediately above. unsafe { task.http.assume_init_mut() }.schedule(&mut batch); + // Out on the HTTP thread until its final callback: the VM aborts it at + // teardown (registry) and waits for it (embedded work). + task.loop_handle.embedded_work_scheduled(); + crate::jsc_hooks::ActiveHandle::S3Request(core::ptr::NonNull::new(task_ptr).expect("task")) + .register(); bun_http::HTTPThread::schedule(batch); Ok(()) } @@ -1227,7 +1236,7 @@ fn download_stream( callback, headers, // `VirtualMachine::get()` returns the live per-thread VM singleton. - vm: Some(bun_ptr::BackRef::new(VirtualMachine::get())), + loop_handle: VirtualMachine::get().loop_handle(), has_schedule_callback: core::sync::atomic::AtomicBool::new(false), signal_store: Default::default(), signals: Default::default(), @@ -1280,11 +1289,12 @@ fn download_stream( task.headers.entries.clone().expect("OOM"), headers_buf, b"", - bun_http::HTTPClientResultCallback::new::( + bun_http::HTTPClientResultCallback::new_with_release::( task_ptr, // SAFETY: `task_ptr` is the heap-allocated task registered above; the // HTTP thread invokes this with that exact pointer. S3HttpDownloadStreamingTask::http_callback, + S3HttpDownloadStreamingTask::release_at_shutdown, ), bun_http::FetchRedirect::Follow, bun_http::async_http::Options { @@ -1304,6 +1314,11 @@ fn download_stream( bun_http::http_thread::init(&Default::default()); let mut batch = bun_threading::thread_pool::Batch::default(); http.schedule(&mut batch); + // Out on the HTTP thread until its final callback: the VM aborts it at + // teardown (registry) and waits for it (embedded work). + task.loop_handle.embedded_work_scheduled(); + crate::jsc_hooks::ActiveHandle::S3Download(core::ptr::NonNull::new(task_ptr).expect("task")) + .register(); bun_http::HTTPThread::schedule(batch); task_ptr } diff --git a/src/runtime/webcore/s3/download_stream.rs b/src/runtime/webcore/s3/download_stream.rs index 4fa8977496b7..24de3c8a204b 100644 --- a/src/runtime/webcore/s3/download_stream.rs +++ b/src/runtime/webcore/s3/download_stream.rs @@ -7,7 +7,6 @@ use bun_event_loop::ConcurrentTask::{AutoDeinit, ConcurrentTask}; use bun_event_loop::{TaskTag, Taskable, task_tag}; use bun_http::{AsyncHTTP, HTTPClientResult, Headers, Signals}; use bun_io::KeepAlive; -use bun_jsc::virtual_machine::VirtualMachine; use bun_s3_signing::credentials::SignResult; use bun_s3_signing::error::S3Error; use bun_threading::Mutex; @@ -18,9 +17,8 @@ pub struct S3HttpDownloadStreamingTask { // `MaybeUninit` because `AsyncHTTP` contains non-null references, so // `mem::zeroed()` can't be used here (mirrors `S3HttpSimpleTask`). pub(crate) http: core::mem::MaybeUninit>, - /// JSC_BORROW: per-thread VM singleton, outlives every task. `None` only in - /// the inert `Default` placeholder (overwritten before the task escapes). - pub(crate) vm: Option>, + /// How the HTTP thread reaches the VM to deliver chunks. + pub(crate) loop_handle: bun_jsc::LoopHandle, pub(crate) sign_result: SignResult, pub(crate) headers: Headers, pub(crate) callback_context: NonNull<()>, @@ -48,34 +46,9 @@ pub struct S3HttpDownloadStreamingTask { // Hot-dispatch tag for `ConcurrentTask::from`. impl Taskable for S3HttpDownloadStreamingTask { const TAG: TaskTag = task_tag::S3HttpDownloadStreamingTask; -} - -impl Default for S3HttpDownloadStreamingTask { - fn default() -> Self { - // only the fields `has_schedule_callback` .. `concurrent_task` - // are observed via this path; the rest are placeholders that the caller (client.rs - // `..Default::default()`) overwrites before the task pointer escapes - // (see S3HttpSimpleTask in simple_request.rs). - Self { - // never read — fully overwritten by `AsyncHTTP::init` before first use. - http: core::mem::MaybeUninit::uninit(), - vm: None, - sign_result: SignResult::default(), - headers: Headers::default(), - callback_context: NonNull::dangling(), - callback: |_, _, _, _| {}, - proxy_url: Box::default(), - has_schedule_callback: AtomicBool::new(false), - signal_store: bun_http::signals::Store::default(), - signals: Signals::default(), - poll_ref: KeepAlive::default(), - mutex: Mutex::default(), - reported_response_buffer: MutableString::default(), - request_error: None, - state: AtomicU64::new(State::default().0), - concurrent_task: ConcurrentTask::default(), - async_http_id: 0, - } + /// As `S3HttpSimpleTask`: the completion frees the context; run it. + unsafe fn release_unrun(this: *mut Self) { + S3HttpDownloadStreamingTask::on_response(this); } } @@ -202,6 +175,7 @@ impl S3HttpDownloadStreamingTask { unsafe { (*this_ptr).mutex.unlock(); if !has_more { + crate::jsc_hooks::ActiveHandle::S3Download(core::ptr::NonNull::new(this_ptr).expect("task")).unregister(); drop(bun_core::heap::take(this_ptr)); } } @@ -327,21 +301,92 @@ impl S3HttpDownloadStreamingTask { // SAFETY: `this` is live for the duration of the HTTP request; HTTPThread holds the only // concurrent reference and `mutex` serializes against `on_response`. `async_http` is the // live HTTP-thread copy, non-null for the callback's duration. Borrows scoped to the call. + let is_done = !result.has_more; + // The final callback is where the HTTP thread hands the request back + // (`embedded_work_finished` below, after `this` may have been freed). + // SAFETY: `this` is live for the duration of the request. + let done_handle = is_done.then(|| unsafe { (*this).loop_handle.clone() }); + // SAFETY: as above; the HTTP thread is the only one touching it here. if unsafe { (*this).process_http_callback(&mut *async_http, result) } { // we are always unlocked here and its safe to enqueue // SAFETY: same exclusivity as above; `task` is the inline `concurrent_task` field of - // this heap request and the queue takes ownership of its `next` link. - let (vm, task) = unsafe { + // this heap request and the queue takes ownership of its `next` link. The VM waits + // for its S3 requests (embedded work) before closing its handle: always queued. + unsafe { let task = core::ptr::NonNull::from( (*this).concurrent_task.from(this, AutoDeinit::ManualDeinit), ); - ((*this).vm.expect("vm set at task creation"), task) + let bun_jsc::vm_handle::Posted::Queued = (*this).loop_handle.post_task(task) else { + unreachable!( + "VM handle closed with an S3 download outstanding on the HTTP thread" + ); + }; + } + } + if let Some(handle) = done_handle { + handle.embedded_work_finished(); + } + } + + /// `HTTPClientResultCallback::release_at_shutdown`: the exiting main + /// thread parked the HTTP thread, which will not call back; hand the + /// download back as failed/finished so its VM's wait ends and the JS + /// thread frees it. + /// + /// # Safety + /// `this` is the live task registered with the callback; HTTP thread parked. + pub(crate) unsafe fn release_at_shutdown(this: *mut ()) { + let this = this.cast::(); + // SAFETY: fn contract — nothing else touches the task now (the JS + // thread is waiting in the HTTP shutdown). + unsafe { + let handle = (*this).loop_handle.clone(); + let should_enqueue = { + let _guard = (*this).mutex.lock_guard(); + let mut state = (*this).get_state(); + state.set_has_more(false); + (*this).request_error = Some(bun_http::Error::Aborted); + state.set_request_error(1); + (*this).set_state(state); + (*this) + .has_schedule_callback + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_ok() }; - // `vm` is the live per-thread VM BackRef captured at task creation; event_loop - // is initialized for the request's lifetime and enqueue is thread-safe (`&self`). - vm.event_loop_shared().enqueue_task_concurrent(task); + if should_enqueue { + let task = core::ptr::NonNull::from( + (*this).concurrent_task.from(this, AutoDeinit::ManualDeinit), + ); + let bun_jsc::vm_handle::Posted::Queued = handle.post_task(task) else { + unreachable!( + "VM handle closed with an S3 download outstanding on the HTTP thread" + ); + }; + } + handle.embedded_work_finished(); } } + + /// VM teardown's stop phase (JS thread): abort the transport so the HTTP + /// thread fails the request promptly and hands it back. + /// + /// # Safety + /// `this` is live (registered ⇒ not yet freed by `on_response`); JS thread. + pub(crate) unsafe fn stop_for_vm_teardown(this: *mut Self) { + // SAFETY: fn contract; `http` is initialised before the task is registered. + unsafe { + (*this).signal_store.aborted.store(true, Ordering::Relaxed); + bun_http::http_thread().schedule_shutdown((*this).http.assume_init_ref()); + } + } + + fn release_portable(&mut self) { + // SAFETY: `http` is always initialised before the task is scheduled / dropped. + let http = unsafe { self.http.assume_init_mut() }; + http.clear_data(); + http.request_headers = Default::default(); + http.client.header_entries = Default::default(); + } } impl Drop for S3HttpDownloadStreamingTask { @@ -354,14 +399,7 @@ impl Drop for S3HttpDownloadStreamingTask { )); // reported_response_buffer, headers, sign_result, range, proxy_url: // dropped automatically (Box/Vec-backed fields). - // SAFETY: `http` is always initialised before the task is scheduled / dropped. - let http = unsafe { self.http.assume_init_mut() }; - http.clear_data(); - // `init` clones the EntryList into task.headers / request_headers / - // client.header_entries, so free the two copies clear_data() skips. - // (Same fix as `S3HttpSimpleTask::drop` in simple_request.rs.) - http.request_headers = Default::default(); - http.client.header_entries = Default::default(); + self.release_portable(); } } diff --git a/src/runtime/webcore/s3/simple_request.rs b/src/runtime/webcore/s3/simple_request.rs index 9f01d31a3df3..977c341ca2ff 100644 --- a/src/runtime/webcore/s3/simple_request.rs +++ b/src/runtime/webcore/s3/simple_request.rs @@ -1,4 +1,5 @@ use core::ffi::c_void; +use core::sync::atomic::Ordering; use bun_core::MutableString; use bun_core::strings; @@ -115,9 +116,8 @@ pub struct S3HttpSimpleTask { // `execute_simple_s3_request` before the task pointer escapes, so every later access (in // `http_callback` / `Drop`) may `assume_init`. pub(crate) http: core::mem::MaybeUninit>, - /// JSC_BORROW: per-thread VM singleton, outlives every task. `None` only in - /// the inert `Default` placeholder (overwritten before the task escapes). - pub(crate) vm: Option>, + /// How the HTTP thread reaches the VM to deliver the response. + pub(crate) loop_handle: bun_jsc::LoopHandle, pub(crate) sign_result: SignResult, pub(crate) headers: Headers, pub(crate) callback_context: *mut c_void, @@ -134,34 +134,18 @@ pub struct S3HttpSimpleTask { /// copy instead of borrowing caller memory. pub(crate) body: Box<[u8]>, pub poll_ref: KeepAlive, + /// The HTTP client's abort flag: set by the VM's stop phase so a request + /// still queued or in flight fails promptly and comes back. + pub(crate) signal_store: bun_http::signals::Store, } impl Taskable for S3HttpSimpleTask { const TAG: TaskTag = task_tag::S3HttpSimpleTask; -} - -// `..Default::default()` requires the whole struct to be Default, so beyond -// `response_buffer`/`result`/`concurrent_task` the remaining fields get -// inert placeholders that callers always overwrite (see client.rs / execute_simple_s3_request). -impl Default for S3HttpSimpleTask { - fn default() -> Self { - fn unset_callback(_: S3UploadResult<'_>, _: *mut c_void) -> JsTerminatedResult<()> { - unreachable!("S3HttpSimpleTask.callback used before being set") - } - Self { - http: core::mem::MaybeUninit::uninit(), - vm: None, - sign_result: SignResult::default(), - headers: Headers::default(), - callback_context: core::ptr::null_mut(), - callback: Callback::Upload(unset_callback), - response_buffer: MutableString::default(), - result: HTTPClientResult::default(), - concurrent_task: ConcurrentTask::default(), - proxy_url: Box::default(), - body: Box::default(), - poll_ref: KeepAlive::default(), - } + /// A response the HTTP thread handed back during teardown: its native + /// completion is what frees the caller's context (and settles a promise + /// nobody can observe — script is forbidden), so run it. + unsafe fn release_unrun(this: *mut Self) { + let _ = S3HttpSimpleTask::on_response(this); } } @@ -322,6 +306,8 @@ impl S3HttpSimpleTask { // pointer the queue hands back, non-null by the `ConcurrentTask::from` contract. #[allow(clippy::not_unsafe_ptr_arg_deref)] pub(crate) fn on_response(this: *mut Self) -> JsTerminatedResult<()> { + crate::jsc_hooks::ActiveHandle::S3Request(core::ptr::NonNull::new(this).expect("task")) + .unregister(); // SAFETY: `this` was produced by `S3HttpSimpleTask::new` (heap::alloc) and ownership is // reclaimed here exactly once via the ConcurrentTask `.manual_deinit` contract; // `this` is dropped at scope exit. @@ -462,17 +448,68 @@ impl S3HttpSimpleTask { unsafe { (*this).stage_http_result(async_http, result) }; if is_done { // SAFETY: same exclusivity as above; the queue takes ownership of the inline - // `concurrent_task` field's `next` link, and each access is scoped. - let (vm, queued) = unsafe { + // `concurrent_task` field's `next` link. The VM waits for its S3 requests + // (embedded work) before closing its handle: always queued. + unsafe { + let handle = (*this).loop_handle.clone(); let queued = core::ptr::NonNull::from( (*this).concurrent_task.from(this, AutoDeinit::ManualDeinit), ); - // `vm` is the live per-thread VM BackRef captured at task creation; event_loop - ((*this).vm.expect("vm set at task creation"), queued) + let bun_jsc::vm_handle::Posted::Queued = handle.post_task(queued) else { + unreachable!( + "VM handle closed with an S3 request outstanding on the HTTP thread" + ); + }; + // The HTTP thread is done with this request (`this` may already be freed). + handle.embedded_work_finished(); + } + } + } + + /// `HTTPClientResultCallback::release_at_shutdown`: the exiting main + /// thread parked the HTTP thread, which will not call back; hand the + /// request back as failed so its VM's wait ends and the JS thread frees it. + /// + /// # Safety + /// `this` is the live task registered with the callback; HTTP thread parked. + pub(crate) unsafe fn release_at_shutdown(this: *mut ()) { + let this = this.cast::(); + // SAFETY: fn contract — nothing else touches the task now. + unsafe { + (*this).result.fail = Some(bun_http::Error::Aborted); + (*this).result.has_more = false; + let handle = (*this).loop_handle.clone(); + let queued = core::ptr::NonNull::from( + (*this).concurrent_task.from(this, AutoDeinit::ManualDeinit), + ); + let bun_jsc::vm_handle::Posted::Queued = handle.post_task(queued) else { + unreachable!("VM handle closed with an S3 request outstanding on the HTTP thread"); }; - vm.event_loop_shared().enqueue_task_concurrent(queued); + handle.embedded_work_finished(); } } + + /// VM teardown's stop phase (JS thread): abort the transport so the HTTP + /// thread fails the request promptly and hands it back. + /// + /// # Safety + /// `this` is live (registered ⇒ its response has not run); JS thread. + pub(crate) unsafe fn stop_for_vm_teardown(this: *mut Self) { + // SAFETY: fn contract; `http` is initialised before the task is registered. + unsafe { + (*this).signal_store.aborted.store(true, Ordering::Relaxed); + bun_http::http_thread().schedule_shutdown((*this).http.assume_init_ref()); + } + } + + fn release_portable(&mut self) { + // SAFETY: `http` is always initialised before the task pointer escapes (see + // `execute_simple_s3_request`). + let http = unsafe { self.http.assume_init_mut() }; + http.clear_data(); + http.request_headers = Default::default(); + http.client.header_entries = Default::default(); + } } impl Drop for S3HttpSimpleTask { @@ -488,16 +525,9 @@ impl Drop for S3HttpSimpleTask { self.poll_ref.unref(bun_io::posix_event_loop::get_vm_ctx( bun_io::AllocatorType::Js, )); - // SAFETY: `http` is always initialised before the task pointer escapes (see - // `execute_simple_s3_request`); `Drop` only runs via `on_response` after that point. - // Only `http.clear_data()` runs here — never a full AsyncHTTP destructor — - // so we intentionally do NOT `assume_init_drop` here. - let http = unsafe { self.http.assume_init_mut() }; - http.clear_data(); - // `init` clones the EntryList into task.headers / request_headers / - // client.header_entries, so free the two copies clear_data() skips. - http.request_headers = Default::default(); - http.client.header_entries = Default::default(); + // Only `http.clear_data()` runs — never a full AsyncHTTP destructor — + // so we intentionally do NOT `assume_init_drop`. + self.release_portable(); } } @@ -552,6 +582,17 @@ pub(crate) fn execute_simple_s3_request( callback: Callback, callback_context: *mut c_void, ) -> JsTerminatedResult<()> { + // A multipart/retry continuation can reach here from teardown's queue + // release; nothing new leaves a VM that is shutting down. + if !VirtualMachine::get().handle().accepting_work() { + drop(options.range); + callback.fail( + b"ERR_S3_VM_SHUTDOWN", + b"The JavaScript VM that owns this request is shutting down", + callback_context, + )?; + return Ok(()); + } let result = match this.sign_request::( &SignOptions { path: options.path, @@ -614,7 +655,7 @@ pub(crate) fn execute_simple_s3_request( callback_context, callback, headers, - vm: Some(bun_ptr::BackRef::new(VirtualMachine::get())), + loop_handle: VirtualMachine::get().loop_handle(), response_buffer: MutableString::default(), result: HTTPClientResult::default(), concurrent_task: ConcurrentTask::default(), @@ -625,6 +666,7 @@ pub(crate) fn execute_simple_s3_request( }, body: Box::<[u8]>::from(options.body), poll_ref, + signal_store: Default::default(), }); // SAFETY: `task_ptr` is a freshly heap-allocated pointer; shared reads only until // the scoped exclusive `http` writes below. @@ -660,17 +702,21 @@ pub(crate) fn execute_simple_s3_request( task.headers.entries.clone().expect("OOM"), headers_buf, body, - HTTPClientResultCallback::new::( + HTTPClientResultCallback::new_with_release::( task_ptr, // SAFETY: `task_ptr` was just heap-allocated above and `async_http` is supplied by // the HTTP thread as a live pointer for the duration of the callback. S3HttpSimpleTask::http_callback, + S3HttpSimpleTask::release_at_shutdown, ), FetchRedirect::Follow, HttpOptions { http_proxy, verbose: Some(verbose), reject_unauthorized: Some(reject_unauthorized), + // SAFETY: `task_ptr` outlives the request; the store is only read + // through these pointers by the HTTP client. + signals: Some(unsafe { (*task_ptr).signal_store.to() }), ..Default::default() }, ); @@ -682,6 +728,12 @@ pub(crate) fn execute_simple_s3_request( let mut batch = thread_pool::Batch::default(); // SAFETY: `http` was initialised immediately above; scoped exclusive access. unsafe { (*task_ptr).http.assume_init_mut() }.schedule(&mut batch); + // Out on the HTTP thread until its final callback: the VM aborts it at + // teardown (registry) and waits for it (embedded work). + // SAFETY: as above. + unsafe { (*task_ptr).loop_handle.embedded_work_scheduled() }; + crate::jsc_hooks::ActiveHandle::S3Request(core::ptr::NonNull::new(task_ptr).expect("task")) + .register(); bun_http::HTTPThread::schedule(batch); Ok(()) } diff --git a/src/runtime/webcore/streams.rs b/src/runtime/webcore/streams.rs index c19b50005e19..91629620e3e5 100644 --- a/src/runtime/webcore/streams.rs +++ b/src/runtime/webcore/streams.rs @@ -585,10 +585,6 @@ impl Pending { // SAFETY: VirtualMachine::get() returns the per-thread singleton VM; sole // `&`-borrow on this thread, outlives this call. let vm = VirtualMachine::get(); - if vm.is_shutting_down() { - return; - } - let clone = Box::new(core::mem::take(self)); // `mem::take` resets `state`/`result`/`future` via `Default`; // no reader observes `future` after this. @@ -612,10 +608,27 @@ impl Pending { boxed.run(); drop(boxed); } + + /// The loop refused the deferred fulfilment (VM teardown): nobody awaits + /// the read any more, so drop the promise's root and the parked result. + pub(crate) fn release_without_running(this: *mut Pending) { + // SAFETY: heap-allocated in run_on_next_tick; refused, so we own it. + let mut boxed = unsafe { bun_core::heap::take(this) }; + boxed.state = PendingState::Used; + if let PendingFuture::Promise { promise, .. } = &boxed.future { + JSPromise::opaque_ref(*promise).to_js().unprotect(); + } + drop(boxed); + } } impl bun_event_loop::Taskable for Pending { const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::StreamPending; + /// Deferred out of a finalizer or a late completion: do the script-free + /// part of what the dispatch would have done. + unsafe fn release_unrun(this: *mut Self) { + Pending::release_without_running(this); + } } pub enum PendingFuture { @@ -751,7 +764,11 @@ impl StreamResult { // `release()` frees `.owned`/`.owned_and_done` ByteLists and // unprotects `.err.JSValue` instead of leaking on the shutdown path. self.release(); - return Ok(JSValue::ZERO); + // No value is produced for a VM that is going away; say so the one + // way callers (a pull promise's settle) understand: a pending + // termination, not an empty "Ok". + global_this.vm().ensure_termination_exception_pending(); + return Err(jsc::JsError::Terminated); } match self { diff --git a/src/runtime/webview/ChromeBackend.cpp b/src/runtime/webview/ChromeBackend.cpp index 16a82f9e7514..cac7f405155d 100644 --- a/src/runtime/webview/ChromeBackend.cpp +++ b/src/runtime/webview/ChromeBackend.cpp @@ -99,7 +99,7 @@ extern "C" int32_t Bun__Chrome__ensure(Zig::GlobalObject*, const char* userDataD bool stdoutInherit, bool stderrInherit); extern "C" void* Blob__fromBytesWithType(JSC::JSGlobalObject*, const uint8_t* ptr, size_t len, const char* mime); extern "C" JSC::EncodedJSValue SYSV_ABI Blob__create(Zig::GlobalObject*, void* impl); -extern "C" void Bun__eventLoop__incrementRefConcurrently(void* bunVM, int delta); +extern "C" void Bun__VmHandle__refKeepAlive(const ::BunVmHandleRef*, int delta); extern "C" void Bun__EventLoop__enter(Zig::GlobalObject*); extern "C" void Bun__EventLoop__exit(Zig::GlobalObject*); extern "C" void Bun__EventLoop__runCallback2(JSGlobalObject*, EncodedJSValue cb, @@ -1306,8 +1306,8 @@ void Transport::updateKeepAlive() bool want = !m_views.isEmpty() || !m_pending.isEmpty(); if (want == m_sockRefd || !m_global) return; m_sockRefd = want; - Bun__eventLoop__incrementRefConcurrently( - WebCore::clientData(m_global->vm())->bunVM, want ? 1 : -1); + Bun__VmHandle__refKeepAlive( + WebCore::clientData(m_global->vm())->vmHandle, want ? 1 : -1); // WebSocket mode: close the connection when the last view is gone. // We're connected to the USER'S Chrome — keeping the WS open after diff --git a/src/runtime/webview/WebKitBackend.cpp b/src/runtime/webview/WebKitBackend.cpp index eeb408562f0a..4f9c85bddc8b 100644 --- a/src/runtime/webview/WebKitBackend.cpp +++ b/src/runtime/webview/WebKitBackend.cpp @@ -44,7 +44,7 @@ extern "C" int32_t Bun__WebViewHost__ensure(Zig::GlobalObject*, bool stdoutInher extern "C" void* Blob__fromMmapWithType(JSC::JSGlobalObject*, uint8_t* ptr, size_t len, const char* mime); extern "C" JSC::EncodedJSValue SYSV_ABI Blob__create(Zig::GlobalObject*, void* impl); extern "C" JSC::EncodedJSValue JSBuffer__fromMmap(Zig::GlobalObject*, void* ptr, size_t length); -extern "C" void Bun__eventLoop__incrementRefConcurrently(void* bunVM, int delta); +extern "C" void Bun__VmHandle__refKeepAlive(const ::BunVmHandleRef*, int delta); // Bracket the whole onData batch. exit() drains microtasks when outermost, // so all the promise reactions from this batch run before we return to usockets. extern "C" void Bun__EventLoop__enter(Zig::GlobalObject*); @@ -123,8 +123,8 @@ void HostClient::updateKeepAlive() bool want = !viewsById.empty(); if (want == sockRefd || !global) return; sockRefd = want; - Bun__eventLoop__incrementRefConcurrently( - WebCore::clientData(global->vm())->bunVM, want ? 1 : -1); + Bun__VmHandle__refKeepAlive( + WebCore::clientData(global->vm())->vmHandle, want ? 1 : -1); } bool HostClient::ensureSpawned(Zig::GlobalObject* zig, bool stdoutInherit, bool stderrInherit) diff --git a/src/runtime/webview/WebViewEventTarget.h b/src/runtime/webview/WebViewEventTarget.h index 9c71ec573fea..508fc39c225e 100644 --- a/src/runtime/webview/WebViewEventTarget.h +++ b/src/runtime/webview/WebViewEventTarget.h @@ -33,8 +33,10 @@ class WebViewEventTarget final : public RefCounted, return adoptRef(*new WebViewEventTarget(ctx)); } - using RefCounted::deref; - using RefCounted::ref; + // ContextDestructionObserver. + void ref() const final { RefCounted::ref(); } + void deref() const final { RefCounted::deref(); } + USING_CAN_MAKE_WEAKPTR(WebCore::EventTargetWithInlineData); private: explicit WebViewEventTarget(WebCore::ScriptExecutionContext& ctx) diff --git a/src/spawn/process.rs b/src/spawn/process.rs index 89fb90478519..dadd2fbb871b 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -128,6 +128,10 @@ pub struct Process { pub(crate) ref_count: bun_ptr::ThreadSafeRefCount, pub exit_handler: ProcessExitHandler, pub(crate) event_loop: EventLoopHandle, + /// How the waiter thread delivers this process's exit to a JS VM + /// (`None` when owned by a mini event loop, which it posts to directly). + #[cfg(unix)] + pub(crate) js_poster: Option, } impl Drop for Process { @@ -223,6 +227,7 @@ impl Process { pid: posix.pid, #[cfg(any(target_os = "linux", target_os = "android"))] pidfd: posix.pidfd.unwrap_or(0), + js_poster: event_loop.js_poster(), event_loop, poller: Poller::Detached, status, @@ -994,6 +999,20 @@ pub mod waiter_thread_posix { pub(crate) rusage: Rusage, } + impl bun_event_loop::Taskable for ResultTask { + const TAG: TaskTag = T::TASK_TAG; + /// An exit status the waiter thread posted whose delivery will not run: + /// drop it and the strong ref it carried for the JS thread. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract — the box `ResultTask::new` made; `subprocess` + // holds the ref taken before `append()`. + unsafe { + let t = bun_core::heap::take(this); + T::release_ref_from_waiter_thread(t.subprocess); + } + } + } + impl ResultTask { #[inline] pub(crate) fn new(v: ResultTask) -> *mut ResultTask { @@ -1052,6 +1071,14 @@ pub mod waiter_thread_posix { const TASK_TAG: TaskTag; fn pid(&self) -> PidT; fn event_loop(&self) -> EventLoopHandle; + /// The poster for a JS-owned process (see `Process::js_poster`). + fn js_poster(&self) -> Option<&bun_event_loop::JsPoster>; + /// Waiter thread, VM gone: release the strong ref the result would have + /// consumed on the JS thread. + /// + /// # Safety + /// `this` is a live, strong-ref'd pointer; callee releases one ref. + unsafe fn release_ref_from_waiter_thread(this: *mut Self); /// # Safety /// `this` must be a live, strong-ref'd pointer; callee releases one ref. unsafe fn on_wait_pid_from_waiter_thread( @@ -1072,6 +1099,15 @@ pub mod waiter_thread_posix { self.event_loop } #[inline] + fn js_poster(&self) -> Option<&bun_event_loop::JsPoster> { + self.js_poster.as_ref() + } + #[inline] + unsafe fn release_ref_from_waiter_thread(this: *mut Self) { + // SAFETY: fn contract. + unsafe { Process::deref(this) }; + } + #[inline] unsafe fn on_wait_pid_from_waiter_thread( this: *mut Self, result: &bun_sys::Result, @@ -1142,17 +1178,25 @@ pub mod waiter_thread_posix { remove = true; match T::event_loop(process_ref) { - EventLoopHandle::Js { owner } => { - let ct = ConcurrentTask::create(Task::new( - T::TASK_TAG, - ResultTask::::new(ResultTask { - result, - subprocess: process, - rusage, - }) - .cast(), - )); - owner.enqueue_task_concurrent(ct); + EventLoopHandle::Js { .. } => { + let rt = ResultTask::::new(ResultTask { + result, + subprocess: process, + rusage, + }); + let ct = ConcurrentTask::create(Task::init(rt)); + let poster = T::js_poster(process_ref) + .expect("JS-owned process has a poster"); + if let bun_event_loop::Posted::Refused(ct) = poster.post(ct) { + // VM torn down: nobody will observe this exit. Free the + // task and drop the ref its delivery would have released. + // SAFETY: refused ⇒ we own both boxes; `process` is strong-ref'd. + unsafe { + drop(bun_core::heap::take(ct.as_ptr())); + drop(bun_core::heap::take(rt)); + T::release_ref_from_waiter_thread(process); + } + } } EventLoopHandle::Mini(mut mini) => { let out = ResultTaskMini::::new(ResultTaskMini { @@ -2070,6 +2114,25 @@ mod spawn_process_body { } return Ok(Err(err)); } + // The process handle is open on this thread's loop until `close()`; a + // thread teardown closes it through us (the child keeps running, as with + // Node's ProcessWrap), so no exit callback can fire after the VM is gone. + unsafe fn stop_for_vm_teardown(p: *mut c_void) { + // SAFETY: recorded for this live Process; the handle leaves the list + // when `close()` issues its uv_close. + unsafe { (*p.cast::()).close() }; + } + // SAFETY: `process` is live; poller was just set to the spawned Uv handle. + unsafe { + let Poller::Uv(ref mut uv_proc) = (*process).poller else { + unreachable!() + }; + uv::open_handles::set_owner( + core::ptr::from_mut(uv_proc).cast(), + process.cast(), + Some(stop_for_vm_teardown), + ); + } // SAFETY: process is valid, poller is Uv unsafe { diff --git a/src/sql_jsc/mysql/JSMySQLConnection.rs b/src/sql_jsc/mysql/JSMySQLConnection.rs index 83e5de60c7bb..bae2128d7a08 100644 --- a/src/sql_jsc/mysql/JSMySQLConnection.rs +++ b/src/sql_jsc/mysql/JSMySQLConnection.rs @@ -367,32 +367,8 @@ impl JSMySQLConnection { self.register_auto_flusher(); } - pub(crate) fn close(&self) { - // Re-enter through a `ParentRef` (lifetime-erased `&Self`) so no Rust - // borrow is held across the potential free in `deref()`. Guard drop - // order is LIFO: `_ref` (deref) drops last, after - // `update_reference_type()` has run, so `*p` is still live when the - // defer body executes. - let p = ParentRef::new(self); - let _ref = self.ref_guard(); - scopeguard::defer! { - p.update_reference_type(); - } - self.stop_timers(); - self.unregister_auto_flusher(); - if self.vm().is_shutting_down() { - self.connection_mut().close(); - } else { - let queries = self.get_queries_array(); - self.connection_mut().clean_queue_and_close(None, queries); - } - } - fn drain_internal(&self) { bun_core::scoped_log!(MySQLConnection, "drainInternal"); - if self.vm().is_shutting_down() { - return self.close(); - } // Raw-pointer RAII guard so no reference is live across the potential // free. let _ref = self.ref_guard(); @@ -669,9 +645,6 @@ impl JSMySQLConnection { } fn consume_on_connect_callback(&self, global_object: &JSGlobalObject) -> Option { - if self.vm().is_shutting_down() { - return None; - } if let Some(value) = self.js_value.get().try_get() { return js::onconnect_take_cached(value, global_object); } @@ -679,9 +652,6 @@ impl JSMySQLConnection { } fn consume_on_close_callback(&self, global_object: &JSGlobalObject) -> Option { - if self.vm().is_shutting_down() { - return None; - } if let Some(value) = self.js_value.get().try_get() { return js::onclose_take_cached(value, global_object); } @@ -689,9 +659,6 @@ impl JSMySQLConnection { } pub(crate) fn get_queries_array(&self) -> JSValue { - if self.vm().is_shutting_down() { - return JSValue::UNDEFINED; - } if let Some(value) = self.js_value.get().try_get() { return js::queries_get_cached(value).unwrap_or(JSValue::UNDEFINED); } @@ -739,12 +706,8 @@ impl JSMySQLConnection { scopeguard::defer! { // `_ref` has not yet dropped, so `*p` is still live; `ParentRef` // yields a fresh `&Self` per access (R-2: every callee is `&self`). - if p.vm().is_shutting_down() { - p.connection_mut().close(); - } else { - let queries = p.get_queries_array(); - p.connection_mut().clean_queue_and_close(Some(value), queries); - } + let queries = p.get_queries_array(); + p.connection_mut().clean_queue_and_close(Some(value), queries); p.update_reference_type(); } self.stop_timers(); @@ -754,10 +717,6 @@ impl JSMySQLConnection { } self.connection_mut().status = my_sql_connection::Status::Failed; - if self.vm().is_shutting_down() { - return; - } - let Some(on_close) = self.consume_on_close_callback(&self.global_object) else { return; }; @@ -793,9 +752,6 @@ impl JSMySQLConnection { } pub(crate) fn on_connection_estabilished(&self) { - if self.vm().is_shutting_down() { - return; - } let Some(on_connect) = self.consume_on_connect_callback(&self.global_object) else { return; }; @@ -829,7 +785,7 @@ impl JSMySQLConnection { ResultMode::Objects => { // Build unconditionally (matches postgres) so toJS always has // either a Structure or a names array. - let owner = self.js_value.get().try_get().unwrap_or(JSValue::ZERO); + let owner = self.js_value.get().try_get().unwrap_or_default(); let cs = statement.structure(owner, &self.global_object); structure = cs.js_value().unwrap_or(JSValue::UNDEFINED); Some(ParentRef::new(cs)) @@ -892,20 +848,12 @@ impl JSMySQLConnection { pub(crate) fn on_error(&self, request: Option<&JSMySQLQuery>, err: AnyMySQLErrorT) { if let Some(request) = request { - if self.vm().is_shutting_down() { - request.mark_as_failed(); - return; - } if let Some(err_) = self.global_object.try_take_exception() { request.reject_with_js_value(self.get_queries_array(), err_); } else { request.reject(self.get_queries_array(), err); } } else { - if self.vm().is_shutting_down() { - self.close(); - return; - } if let Some(err_) = self.global_object.try_take_exception() { self.fail_with_js_value(err_); } else { @@ -916,23 +864,13 @@ impl JSMySQLConnection { pub(crate) fn on_error_packet(&self, request: Option<&JSMySQLQuery>, err: &ErrorPacket) { if let Some(request) = request { - if self.vm().is_shutting_down() { - request.mark_as_failed(); + if let Some(err_) = self.global_object.try_take_exception() { + request.reject_with_js_value(self.get_queries_array(), err_); } else { - if let Some(err_) = self.global_object.try_take_exception() { - request.reject_with_js_value(self.get_queries_array(), err_); - } else { - request.reject_with_js_value( - self.get_queries_array(), - err.to_js(&self.global_object), - ); - } + request + .reject_with_js_value(self.get_queries_array(), err.to_js(&self.global_object)); } } else { - if self.vm().is_shutting_down() { - self.close(); - return; - } if let Some(err_) = self.global_object.try_take_exception() { self.fail_with_js_value(err_); } else { @@ -1064,11 +1002,6 @@ impl SocketHandler { p.update_reference_type(); p.register_auto_flusher(); } - if this.vm().is_shutting_down() { - // we are shutting down lets not process the data - return; - } - let _loop_guard = this.event_loop().entered(); this.ensure_js_value_is_alive(); diff --git a/src/sql_jsc/mysql/JSMySQLQuery.rs b/src/sql_jsc/mysql/JSMySQLQuery.rs index 89cffab65fac..a3ded9ee05f0 100644 --- a/src/sql_jsc/mysql/JSMySQLQuery.rs +++ b/src/sql_jsc/mysql/JSMySQLQuery.rs @@ -250,9 +250,6 @@ impl JSMySQLQuery { if !self.query.with_mut(|q| q.result(is_last_result)) { return; } - if self.vm().is_shutting_down() { - return; - } let Some(target_value) = self.get_target() else { return; @@ -317,10 +314,6 @@ impl JSMySQLQuery { } pub(crate) fn reject(&self, queries_array: JSValue, err: AnyMySQLError::Error) { - if self.vm().is_shutting_down() { - self.mark_as_failed(); - return; - } if let Some(err_) = self.global_object().try_take_exception() { self.reject_with_js_value(queries_array, err_); } else { @@ -346,9 +339,6 @@ impl JSMySQLQuery { return; } - if self.vm().is_shutting_down() { - return; - } let Some(target_value) = self.get_target() else { return; }; @@ -392,11 +382,6 @@ impl JSMySQLQuery { } pub(crate) fn run(&self, connection: &MySQLConnection) -> Result<(), AnyMySQLError::Error> { - if self.vm().is_shutting_down() { - debug!("run cannot run a query if the VM is shutting down"); - // cannot run a query if the VM is shutting down - return Ok(()); - } { let q = self.query.get(); if !q.is_pending() || q.is_being_prepared() { @@ -489,18 +474,12 @@ impl JSMySQLQuery { #[inline] pub(crate) fn set_pending_value(&self, result: JSValue) { - if self.vm().is_shutting_down() { - return; - } if let Some(value) = self.this_value.get().try_get() { js::pending_value_set_cached(value, self.global_object(), result); } } #[inline] pub(crate) fn get_pending_value(&self) -> Option { - if self.vm().is_shutting_down() { - return None; - } if let Some(value) = self.this_value.get().try_get() { return js::pending_value_get_cached(value); } @@ -509,18 +488,12 @@ impl JSMySQLQuery { #[inline] fn set_target(&self, result: JSValue) { - if self.vm().is_shutting_down() { - return; - } if let Some(value) = self.this_value.get().try_get() { js::target_set_cached(value, self.global_object(), result); } } #[inline] fn get_target(&self) -> Option { - if self.vm().is_shutting_down() { - return None; - } if let Some(value) = self.this_value.get().try_get() { return js::target_get_cached(value); } @@ -529,18 +502,12 @@ impl JSMySQLQuery { #[inline] fn set_columns(&self, result: JSValue) { - if self.vm().is_shutting_down() { - return; - } if let Some(value) = self.this_value.get().try_get() { js::columns_set_cached(value, self.global_object(), result); } } #[inline] fn get_columns(&self) -> Option { - if self.vm().is_shutting_down() { - return None; - } if let Some(value) = self.this_value.get().try_get() { return js::columns_get_cached(value); } @@ -548,18 +515,12 @@ impl JSMySQLQuery { } #[inline] fn set_binding(&self, result: JSValue) { - if self.vm().is_shutting_down() { - return; - } if let Some(value) = self.this_value.get().try_get() { js::binding_set_cached(value, self.global_object(), result); } } #[inline] fn get_binding(&self) -> Option { - if self.vm().is_shutting_down() { - return None; - } if let Some(value) = self.this_value.get().try_get() { return js::binding_get_cached(value); } diff --git a/src/sql_jsc/postgres/PostgresSQLConnection.rs b/src/sql_jsc/postgres/PostgresSQLConnection.rs index 6c25322980de..79acc2828f2f 100644 --- a/src/sql_jsc/postgres/PostgresSQLConnection.rs +++ b/src/sql_jsc/postgres/PostgresSQLConnection.rs @@ -625,11 +625,6 @@ impl PostgresSQLConnection { self.status.set(status); self.reset_connection_timeout(); - if self.vm().is_shutting_down() { - self.update_has_pending_activity(); - return; - } - match status { Status::Connected => { let Some(on_connect) = self.consume_on_connect_callback(self.global()) else { @@ -787,25 +782,11 @@ impl PostgresSQLConnection { fn handle_socket_failure(&self, fail: impl FnOnce(&Self)) { self.unregister_auto_flusher(); - - if self.vm().is_shutting_down() { - self.stop_timers(); - if self.status.get() == Status::Failed { - self.update_has_pending_activity(); - return; - } - - self.status.set(Status::Failed); - self.clean_up_requests(None); - self.update_has_pending_activity(); - } else { - let event_loop = self.event_loop(); - event_loop.enter(); - self.poll_ref.with_mut(|r| r.unref(self.vm_ctx())); - - fail(self); - event_loop.exit(); - } + let event_loop = self.event_loop(); + event_loop.enter(); + self.poll_ref.with_mut(|r| r.unref(self.vm_ctx())); + fail(self); + event_loop.exit(); } fn send_startup_message(&self) { @@ -945,10 +926,6 @@ impl PostgresSQLConnection { fn drain_internal(&self) { debug!("drainInternal"); - if self.vm().is_shutting_down() { - return self.close(); - } - let event_loop = self.event_loop(); event_loop.enter(); @@ -1315,11 +1292,6 @@ impl SocketHandler { /// intentionally do NOT route through this — they forward unconditionally. #[inline] fn guarded(this: &PostgresSQLConnection, f: impl FnOnce(&PostgresSQLConnection)) { - if this.vm().is_shutting_down() { - bun_core::hint::cold(); - this.close(); - return; - } f(this) } @@ -1390,12 +1362,10 @@ impl PostgresSQLConnection { // callback fires, pending queries are rejected, and the in-flight // socket is torn down instead of completing the handshake after // close. - if !self.vm().is_shutting_down() - && matches!( - self.status.get(), - Status::Connecting | Status::SentStartupMessage - ) - { + if matches!( + self.status.get(), + Status::Connecting | Status::SentStartupMessage + ) { self.fail(b"Connection closed", AnyPostgresError::ConnectionClosed); // closing an in-flight connect dispatches no socket event, so the // poll ref taken at creation is released here rather than in a @@ -1492,31 +1462,27 @@ impl PostgresSQLConnection { AnyPostgresError::ConnectionClosed, )); stmt.status = StatementStatus::Failed; - if !self.vm().is_shutting_down() { - let global = self.global(); - if let Some(reason) = js_reason { - request.on_js_error(reason, global); - } else { - request.on_error( - &StatementError::PostgresError(AnyPostgresError::ConnectionClosed), - global, - ); - } + let global = self.global(); + if let Some(reason) = js_reason { + request.on_js_error(reason, global); + } else { + request.on_error( + &StatementError::PostgresError(AnyPostgresError::ConnectionClosed), + global, + ); } } // in the middle of running QueryStatus::Binding | QueryStatus::Running | QueryStatus::PartialResponse => { self.finish_request(&request); - if !self.vm().is_shutting_down() { - let global = self.global(); - if let Some(reason) = js_reason { - request.on_js_error(reason, global); - } else { - request.on_error( - &StatementError::PostgresError(AnyPostgresError::ConnectionClosed), - global, - ); - } + let global = self.global(); + if let Some(reason) = js_reason { + request.on_js_error(reason, global); + } else { + request.on_error( + &StatementError::PostgresError(AnyPostgresError::ConnectionClosed), + global, + ); } } // just ignore success and fail cases @@ -1874,12 +1840,6 @@ impl PostgresSQLConnection { while self.requests.get().readable_length() > offset && !self.flags.get().contains(ConnectionFlags::HAS_BACKPRESSURE) { - if self.vm().is_shutting_down() { - self.close(); - defer_cleanup!(self); - return; - } - let req_ptr: *mut PostgresSQLQuery = self.requests.get().peek_item(offset); // Queue invariant: every stored pointer is non-null and live // (refcount ≥ 1 held by the queue). R-2: `ParentRef` yields `&T` @@ -1982,10 +1942,10 @@ impl PostgresSQLConnection { }; let binding_value = postgres_sql_query::js::binding_get_cached(this_value) - .unwrap_or(JSValue::ZERO); + .unwrap_or_default(); let columns_value = postgres_sql_query::js::columns_get_cached(this_value) - .unwrap_or(JSValue::ZERO); + .unwrap_or_default(); req.update_flags(|f| f.binary = !statement.fields.is_empty()); if self @@ -2122,7 +2082,7 @@ impl PostgresSQLConnection { // prepareAndQueryWithSignature will write + bind + execute, it will change to running after binding is complete let binding_value = postgres_sql_query::js::binding_get_cached(this_value) - .unwrap_or(JSValue::ZERO); + .unwrap_or_default(); debug!("prepareAndQueryWithSignature"); let global = self.global_object; if let Err(err) = @@ -2191,10 +2151,10 @@ impl PostgresSQLConnection { }; let binding_value = postgres_sql_query::js::binding_get_cached(this_value) - .unwrap_or(JSValue::ZERO); + .unwrap_or_default(); let columns_value = postgres_sql_query::js::columns_get_cached(this_value) - .unwrap_or(JSValue::ZERO); + .unwrap_or_default(); debug!("parseAndBindAndExecute (unnamed, first execution)"); let global = self.global_object; if let Err(err) = @@ -2412,7 +2372,7 @@ impl PostgresSQLConnection { // explicit use switch without else so if new modes are added, we don't forget to check for duplicate fields match request_flags.result_mode { SQLQueryResultMode::Objects => { - let owner = self.js_value.get().try_get().unwrap_or(JSValue::ZERO); + let owner = self.js_value.get().try_get().unwrap_or_default(); let cs = statement.structure(owner, self.global()); structure = cs.js_value().unwrap_or(JSValue::UNDEFINED); cached_structure = Some(ParentRef::new(cs)); @@ -2488,7 +2448,7 @@ impl PostgresSQLConnection { return Err(AnyPostgresError::ExpectedRequest); }; let pending_value = postgres_sql_query::js::pending_value_get_cached(this_value) - .unwrap_or(JSValue::ZERO); + .unwrap_or_default(); pending_value.ensure_still_alive(); let result = putter.to_js( self.global(), @@ -2552,7 +2512,7 @@ impl PostgresSQLConnection { request.on_result( b"", self.global(), - self.js_value.get().try_get().unwrap_or(JSValue::ZERO), + self.js_value.get().try_get().unwrap_or_default(), true, ); } @@ -2575,7 +2535,7 @@ impl PostgresSQLConnection { request.on_result( cmd.command_tag.slice(), self.global(), - self.js_value.get().try_get().unwrap_or(JSValue::ZERO), + self.js_value.get().try_get().unwrap_or_default(), false, ); self.update_ref(); diff --git a/src/sql_jsc/postgres/PostgresSQLQuery.rs b/src/sql_jsc/postgres/PostgresSQLQuery.rs index 6a2ca778c47a..22bac088c918 100644 --- a/src/sql_jsc/postgres/PostgresSQLQuery.rs +++ b/src/sql_jsc/postgres/PostgresSQLQuery.rs @@ -491,7 +491,7 @@ impl PostgresSQLQuery { } let this_value = callframe.this(); - let binding_value = js::binding_get_cached(this_value).unwrap_or(JSValue::ZERO); + let binding_value = js::binding_get_cached(this_value).unwrap_or_default(); let query_str = this.query.to_utf8(); // query_str: Utf8Slice<'_> — Drop frees. let writer = connection.writer(); diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 7fe66e92aea6..bd0c3a6afec9 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -101,7 +101,7 @@ pub struct SocketAddress { pub is_ipv6: bool, } -pub use bun_uws_sys::loop_::on_thread_exit; +pub use bun_uws_sys::loop_::free_thread_loop; /// # Safety /// `filename` and `error_msg` must be valid NUL-terminated C strings. diff --git a/src/uws_sys/Loop.rs b/src/uws_sys/Loop.rs index 9d5e8c38e777..5120f7d8bb40 100644 --- a/src/uws_sys/Loop.rs +++ b/src/uws_sys/Loop.rs @@ -653,12 +653,14 @@ mod c { pub use c::{us_loop_run, us_wakeup_loop}; unsafe extern "C" { - // safe: no args; clears the C side's thread-local loop pointer — no preconditions. - safe fn bun_clear_loop_at_thread_exit(); + // safe: no args; frees this thread's lazily-created uws loop if it exists. + safe fn bun_free_loop_at_thread_exit(); } -/// Clears the C side's thread-local loop pointer. Call when a thread that ran -/// a uws loop (e.g. a Worker thread) exits. -pub fn on_thread_exit() { - bun_clear_loop_at_thread_exit() +/// Frees this thread's uws loop (its socket groups, timers and — where uSockets +/// created it — the native loop). Call when a thread that ran a uws loop (a +/// Worker) exits, after everything registered on the loop is gone. On Windows +/// the loop borrows the thread's libuv loop; close that afterwards. +pub fn free_thread_loop() { + bun_free_loop_at_thread_exit() } diff --git a/src/uws_sys/lib.rs b/src/uws_sys/lib.rs index b0da133ea77c..02414316fc9c 100644 --- a/src/uws_sys/lib.rs +++ b/src/uws_sys/lib.rs @@ -199,6 +199,7 @@ unsafe extern "C" { safe fn UpgradedDuplex__shutdown(this: &mut UpgradedDuplex); safe fn UpgradedDuplex__shutdown_read(this: &mut UpgradedDuplex); safe fn UpgradedDuplex__close(this: &mut UpgradedDuplex); + safe fn UpgradedDuplex__abandon_js_side(this: &mut UpgradedDuplex); } impl UpgradedDuplex { #[inline] @@ -256,6 +257,10 @@ impl UpgradedDuplex { pub(crate) fn close(&mut self) { UpgradedDuplex__close(self) } + #[inline] + pub(crate) fn abandon_js_side(&mut self) { + UpgradedDuplex__abandon_js_side(self) + } } // ── WindowsNamedPipe (cycle-break shim) ───────────────────────────────────── diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index 1effe11c0026..89a62dcca2b1 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -2068,9 +2068,10 @@ __attribute__((callback (corker, ctx))) } } - // we need to manually call this at thread exit - extern "C" void bun_clear_loop_at_thread_exit() { - uWS::Loop::clearLoopAtThreadExit(); + // A thread that ran a uws loop (a Worker) is exiting; free its loop. On Windows the loop sits on + // the thread's libuv loop, which the caller closes after this returns. + extern "C" void bun_free_loop_at_thread_exit() { + uWS::Loop::freeLoopAtThreadExit(); } #pragma clang attribute pop diff --git a/src/uws_sys/socket.rs b/src/uws_sys/socket.rs index 394ab83eaa94..6908661da0d4 100644 --- a/src/uws_sys/socket.rs +++ b/src/uws_sys/socket.rs @@ -346,6 +346,18 @@ impl NewSocketHandler { ) } + /// The JS wrapper that owns this socket is being finalized: whatever the + /// close below unwinds must not reach back into JS objects. + pub fn prepare_for_finalize(&self) { + on_socket!(self.socket; + connected _s => {}, + connecting _c => {}, + detached => {}, + duplex d => d.abandon_js_side(), + pipe _p => {}, + ) + } + pub fn shutdown(&self) { on_socket!(self.socket; connected s => s.shutdown(), diff --git a/test/internal/source-lints/empty-jsvalue-laundering.test.ts b/test/internal/source-lints/empty-jsvalue-laundering.test.ts new file mode 100644 index 000000000000..1be2cb5f5fee --- /dev/null +++ b/test/internal/source-lints/empty-jsvalue-laundering.test.ts @@ -0,0 +1,73 @@ +import { file } from "bun"; +import { expect, test } from "bun:test"; +import { realpathSync } from "fs"; +import path from "path"; +import { globAllSources } from "../../../scripts/glob-sources.ts"; + +// An empty `JSValue` is not a value: by JSC convention it means "an exception is +// pending on the VM". A native completion that converts its result to JS can +// see that conversion fail — a `worker.terminate()` landing mid-conversion is +// the common case — and `to_js(..).unwrap_or(JSValue::ZERO)` then hands the +// empty value on to a promise settlement / callback argument / property store, +// where JSC asserts or crashes. +// +// Carry the `JsResult` to the boundary instead: +// promise.resolve(global, v.unwrap_or(JSValue::ZERO)) → promise.settle(global, v) +// cb.call(global, this, &[v.unwrap_or(JSValue::ZERO)]) → let Ok(v) = v else { report/return } +// fn host_getter(..) -> JSValue { v.unwrap_or(ZERO) } → v.or_pending_exception() (bun_jsc::HostReturn) +// opt.unwrap_or(JSValue::ZERO) (an Option) → opt.unwrap_or_default() +// +// `JSPromise::{resolve,reject}` also refuse an empty value at runtime (they turn +// it into "reject with the pending exception", or bail on a termination); this +// lint keeps the laundering pattern from being written in the first place. + +const root = path.resolve(import.meta.dir, "..", "..", ".."); +const rustSources = globAllSources().rust.filter(abs => abs.endsWith(".rs")); + +const tracked: Set | null = (() => { + const r = Bun.spawnSync({ + cmd: ["git", "-C", root, "ls-tree", "-r", "--name-only", "-z", "HEAD"], + stdout: "pipe", + stderr: "ignore", + }); + if (!r.success) return null; + return new Set(r.stdout.toString().split("\0").filter(Boolean)); +})(); + +const BANNED: { name: string; re: RegExp; hint: string }[] = [ + { + name: "unwrap_or(JSValue::ZERO)", + re: /\.unwrap_or\(\s*JSValue::ZERO\s*\)/g, + hint: "carry the JsResult to the boundary (JSPromise::settle / `?` / HostReturn::or_pending_exception); for an Option use unwrap_or_default()", + }, + { + name: "unwrap_or_else(|_| JSValue::ZERO)", + re: /\.unwrap_or_else\(\s*\|_\|\s*JSValue::ZERO\s*\)/g, + hint: "as above", + }, +]; + +const offenders: string[] = []; +let scanned = 0; +for (const abs of rustSources) { + const source = path.relative(root, abs).replaceAll(path.sep, "/"); + if (path.relative(root, realpathSync(abs)).replaceAll(path.sep, "/") !== source) continue; + if (tracked !== null && !tracked.has(source)) continue; + scanned++; + const content = await file(abs).text(); + const stripped = content.replace(/^[ \t]*\/\/.*$/gm, ""); + for (const { name, re, hint } of BANNED) { + for (const m of stripped.matchAll(re)) { + const line = stripped.slice(0, m.index).split("\n").length; + offenders.push(`${source}:${line}: ${name} → ${hint}`); + } + } +} + +test("scans a non-empty set of tracked Rust sources", () => { + expect(scanned).toBeGreaterThan(0); +}); + +test("no JsResult is laundered into an empty JSValue", () => { + expect(offenders).toEqual([]); +}); diff --git a/test/js/node/inspector/inspector-profiler.test.ts b/test/js/node/inspector/inspector-profiler.test.ts index 30518c0178ee..e8783f56bab8 100644 --- a/test/js/node/inspector/inspector-profiler.test.ts +++ b/test/js/node/inspector/inspector-profiler.test.ts @@ -458,13 +458,24 @@ describe("node:inspector", () => { }); // Unlike V8 (which has always-on invocation counters), JSC has none, so - // best-effort coverage is empty until startPreciseCoverage has run. - test("getBestEffortCoverage returns [] without a prior startPreciseCoverage", () => { - const session = new inspector.Session(); - session.connect(); - const { result } = session.post("Profiler.getBestEffortCoverage"); - expect(result).toEqual([]); - session.disconnect(); + // best-effort coverage is empty until startPreciseCoverage has run in the + // process (once started, the profiler stays for the VM's lifetime). + test("getBestEffortCoverage returns [] without a prior startPreciseCoverage", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Session } = require("node:inspector"); + const session = new Session(); + session.connect(); + console.log(JSON.stringify(session.post("Profiler.getBestEffortCoverage").result));`, + ], + env: bunEnv, + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("[]\n"); + expect(exitCode).toBe(0); }); // CDP contract: takePreciseCoverage resets execution counters, so a second @@ -504,6 +515,41 @@ console.log(JSON.stringify({ first: countFor(first), second: countFor(second) }) expect(JSON.parse(stdout.trim())).toEqual({ first: 3, second: 1 }); }); + // The VM's profiler outlives a stop, so a later start must re-base its + // counters: executions between stop and the next start are not reported. + test.concurrent("startPreciseCoverage after a stop counts from zero", async () => { + using dir = tempDir("inspector-coverage-restart", { + "fixture.mjs": ` +import { Session } from "node:inspector/promises"; +import vm from "node:vm"; +const session = new Session(); +session.connect(); +await session.post("Profiler.enable"); +await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true }); +const url = "file:///restart-fixture/virtual.js"; +const f = vm.runInThisContext("function f(){return 1}; f", { filename: url }); +f(); +await session.post("Profiler.takePreciseCoverage"); +await session.post("Profiler.stopPreciseCoverage"); +for (let i = 0; i < 100; i++) f(); +await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true }); +f(); f(); +const after = await session.post("Profiler.takePreciseCoverage"); +session.disconnect(); +const bodyOffset = "function f(){".length; +const entry = after.result.find(s => s.url === url); +const fn = entry?.functions + .filter(f => f.ranges[0].startOffset <= bodyOffset && bodyOffset < f.ranges[0].endOffset) + .sort((a, b) => a.ranges[0].endOffset - b.ranges[0].endOffset)[0]; +console.log(JSON.stringify({ count: fn?.ranges[0].count })); +`, + }); + await using proc = Bun.spawn({ cmd: [bunExe(), "fixture.mjs"], env: bunEnv, cwd: String(dir), stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderrIfFailed: exitCode === 0 ? "" : stderr, exitCode }).toEqual({ stderrIfFailed: "", exitCode: 0 }); + expect(JSON.parse(stdout.trim())).toEqual({ count: 2 }); + }); + test.concurrent("collects block coverage with call counts for vm scripts", async () => { using dir = tempDir("inspector-coverage-vm", { "fixture.mjs": coverageVmFixture, diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 2dc2a4617c76..3f4e0c0a2cb9 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -2338,3 +2338,49 @@ describe("NODE_NO_WARNINGS", () => { expect(await warn("1")).not.toMatch(/Warning: foo/); }); }); + +it("process.exit() does not run microtasks or nextTicks that were queued before it", async () => { + // Node runs 'exit' handlers and nothing queued before them; the exit-time + // teardown must discard, not drain, the pre-exit microtask/nextTick queues. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `process.nextTick(() => console.log("TICK_FIRED")); + queueMicrotask(() => console.log("MICROTASK_FIRED")); + Promise.resolve().then(() => console.log("THEN_FIRED")); + process.on("exit", () => console.log("exit handler")); + process.exit(0);`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("exit handler\n"); + expect(exitCode).toBe(0); +}); + +// Node runs its environment cleanup with JS execution disallowed: closing the +// process's sockets/servers at exit dispatches no 'close'/'error' handlers, so +// nothing of the user's runs after the 'exit' event. +it("no socket close handler runs after the 'exit' event", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {}, close() { console.log("server socket closed after exit"); } } }); + Bun.connect({ hostname: "127.0.0.1", port: server.port, socket: { + data() {}, + close() { console.log("client socket closed after exit"); }, + open() { process.on("exit", () => console.log("exit")); process.exit(0); }, + } });`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("exit\n"); + expect(exitCode).toBe(0); +}); diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index 9257dacaa43c..a07bf4e1b94b 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -2374,9 +2374,9 @@ test("bun:sqlite still initializes correctly when node:sqlite opens a database f void stderr; }); -// Worker-owned databases are closed via ~VM → lastChanceToFinalize → -// ~JSDatabaseSync — a completely different path than the main-thread exit -// sweep. Sibling of "unclosed file-backed database is closed on process exit". +// Worker-owned databases are checkpointed and closed by the worker's own exit +// (the same sweep the main thread runs, filtered to that worker's entries). +// Sibling of "unclosed file-backed database is closed on process exit". test("worker-owned unclosed database is checkpointed on worker exit", async () => { using dir = tempDir("node-sqlite-worker-exit", { "worker.mjs": `import { DatabaseSync } from 'node:sqlite'; @@ -2395,7 +2395,7 @@ test("worker-owned unclosed database is checkpointed on worker exit", async () = w.on('error', rej); w.on('exit', code => (code === 0 ? res() : rej(new Error('exit ' + code)))); }); - // ~JSDatabaseSync on lastChanceToFinalize checkpointed: the -wal is + // The worker's exit sweep checkpointed and closed it: the -wal is // gone or empty. Checked before the reopen below touches the sidecars. console.log(existsSync('exit.db-wal') ? statSync('exit.db-wal').size : 0); const { DatabaseSync } = await import('node:sqlite'); diff --git a/test/js/node/test/parallel/test-worker-cleanup-handles.js b/test/js/node/test/parallel/test-worker-cleanup-handles.js new file mode 100644 index 000000000000..0ed3c747807b --- /dev/null +++ b/test/js/node/test/parallel/test-worker-cleanup-handles.js @@ -0,0 +1,29 @@ +'use strict'; +const common = require('../common'); + +const assert = require('assert'); +const fs = require('fs'); +const { Server } = require('net'); +const { Worker, isMainThread, parentPort } = require('worker_threads'); + +if (isMainThread) { + const w = new Worker(__filename); + let fd = null; + w.on('message', common.mustCall((fd_) => { + assert.strictEqual(typeof fd_, 'number'); + fd = fd_; + })); + w.on('exit', common.mustCall(() => { + if (fd === -1) { + // This happens when server sockets don’t have file descriptors, + // i.e. on Windows. + return; + } + assert.throws(() => fs.fstatSync(fd), { code: 'EBADF' }); + })); +} else { + const server = new Server(); + server.listen(0); + parentPort.postMessage(server._handle.fd); + server.unref(); +} diff --git a/test/js/node/test/parallel/test-worker-dispose.mjs b/test/js/node/test/parallel/test-worker-dispose.mjs new file mode 100644 index 000000000000..770c91d51ca7 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-dispose.mjs @@ -0,0 +1,8 @@ +import * as common from '../common/index.mjs'; +import { Worker } from 'node:worker_threads'; + +{ + // Verifies that the worker is async disposable + await using worker = new Worker('for(;;) {}', { eval: true }); + worker.on('exit', common.mustCall()); +} diff --git a/test/js/node/test/parallel/test-worker-execargv-invalid.js b/test/js/node/test/parallel/test-worker-execargv-invalid.js new file mode 100644 index 000000000000..06c33c678dbc --- /dev/null +++ b/test/js/node/test/parallel/test-worker-execargv-invalid.js @@ -0,0 +1,53 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { Worker } = require('worker_threads'); + +if (process.config.variables.node_without_node_options) { + common.skip('missing NODE_OPTIONS support'); +} + +{ + const expectedErr = { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError' + }; + + assert.throws(() => { + new Worker(__filename, { execArgv: 'hello' }); + }, expectedErr); + assert.throws(() => { + new Worker(__filename, { execArgv: 6 }); + }, expectedErr); +} + +{ + const expectedErr = { + code: 'ERR_WORKER_INVALID_EXEC_ARGV', + name: 'Error' + }; + assert.throws(() => { + new Worker(__filename, { execArgv: ['--foo'] }); + }, expectedErr); + assert.throws(() => { + new Worker(__filename, { execArgv: ['--title=blah'] }); + }, expectedErr); + assert.throws(() => { + new Worker(__filename, { execArgv: ['--redirect-warnings'] }); + }, expectedErr); +} + +{ + const expectedErr = { + code: 'ERR_WORKER_INVALID_EXEC_ARGV', + name: 'Error' + }; + assert.throws(() => { + new Worker(__filename, { + env: { + NODE_OPTIONS: '--nonexistent-options' + } + }); + }, expectedErr); +} diff --git a/test/js/node/test/parallel/test-worker-http2-stream-terminate.js b/test/js/node/test/parallel/test-worker-http2-stream-terminate.js new file mode 100644 index 000000000000..128a79c3186d --- /dev/null +++ b/test/js/node/test/parallel/test-worker-http2-stream-terminate.js @@ -0,0 +1,63 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const assert = require('assert'); +const http2 = require('http2'); +const { duplexPair } = require('stream'); +const { parentPort, Worker } = require('worker_threads'); + +// This test ensures that workers can be terminated without error while +// stream activity is ongoing, in particular the C++ function +// ReportWritesToJSStreamListener::OnStreamAfterReqFinished. + +const MAX_ITERATIONS = 5; +const MAX_THREADS = 6; + +// Do not use isMainThread so that this test itself can be run inside a Worker. +if (!process.env.HAS_STARTED_WORKER) { + process.env.HAS_STARTED_WORKER = 1; + + function spinWorker(iter) { + const w = new Worker(__filename); + w.on('message', common.mustCall((msg) => { + assert.strictEqual(msg, 'terminate'); + w.terminate(); + })); + + w.on('exit', common.mustCall(() => { + if (iter < MAX_ITERATIONS) + spinWorker(++iter); + })); + } + + for (let i = 0; i < MAX_THREADS; i++) { + spinWorker(0); + } +} else { + const server = http2.createServer(); + let i = 0; + server.on('stream', (stream, headers) => { + if (i === 1) { + parentPort.postMessage('terminate'); + } + i++; + + stream.end(''); + }); + + const [ clientSide, serverSide ] = duplexPair(); + server.emit('connection', serverSide); + + const client = http2.connect('http://localhost:80', { + createConnection: () => clientSide, + }); + + function makeRequests() { + for (let i = 0; i < 3; i++) { + client.request().end(); + } + setImmediate(makeRequests); + } + makeRequests(); +} diff --git a/test/js/node/test/parallel/test-worker-message-port-infinite-message-loop.js b/test/js/node/test/parallel/test-worker-message-port-infinite-message-loop.js new file mode 100644 index 000000000000..d5924d9c3bd0 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-message-port-infinite-message-loop.js @@ -0,0 +1,29 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); + +const { MessageChannel } = require('worker_threads'); + +// Make sure that an infinite asynchronous .on('message')/postMessage loop +// does not lead to a stack overflow and does not starve the event loop. +// We schedule timeouts both from before the .on('message') handler and +// inside of it, which both should run. + +const { port1, port2 } = new MessageChannel(); +let count = 0; +port1.on('message', common.mustCallAtLeast(() => { + if (count === 0) { + setTimeout(common.mustCall(() => { + port1.close(); + }), 0); + } + + port2.postMessage(0); + assert(count++ < 10000, `hit ${count} loop iterations`); +})); + +port2.postMessage(0); + +// This is part of the test -- the event loop should be available and not stall +// out due to the recursive .postMessage() calls. +setTimeout(common.mustCall(), 0); diff --git a/test/js/node/test/parallel/test-worker-node-options.js b/test/js/node/test/parallel/test-worker-node-options.js new file mode 100644 index 000000000000..73f71b57ac76 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-node-options.js @@ -0,0 +1,40 @@ +'use strict'; + +const common = require('../common'); +const { + spawnSyncAndExitWithoutError, + spawnSyncAndAssert, +} = require('../common/child_process'); + +if (process.config.variables.node_without_node_options) { + common.skip('missing NODE_OPTIONS support'); +} + +const fixtures = require('../common/fixtures'); +spawnSyncAndExitWithoutError( + process.execPath, + [ + fixtures.path('spawn-worker-with-copied-env'), + ], + { + env: { + ...process.env, + NODE_OPTIONS: '--title=foo' + } + } +); + +spawnSyncAndAssert( + process.execPath, + [ + fixtures.path('spawn-worker-with-trace-exit'), + ], + { + env: { + ...process.env, + } + }, + { + stderr: /spawn-worker-with-trace-exit\.js:17/ + } +); diff --git a/test/js/node/test/parallel/test-worker-unsupported-eval-on-url.mjs b/test/js/node/test/parallel/test-worker-unsupported-eval-on-url.mjs new file mode 100644 index 000000000000..d5ff6a8548d2 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-unsupported-eval-on-url.mjs @@ -0,0 +1,6 @@ +import '../common/index.mjs'; +import assert from 'assert'; +import { Worker } from 'worker_threads'; + +const re = /The property 'options\.eval' must be false when 'filename' is not a string\./; +assert.throws(() => new Worker(new URL(import.meta.url), { eval: true }), re); diff --git a/test/js/node/worker_threads/worker_destruction.test.ts b/test/js/node/worker_threads/worker_destruction.test.ts index e0303327d7d6..02855dd90ce5 100644 --- a/test/js/node/worker_threads/worker_destruction.test.ts +++ b/test/js/node/worker_threads/worker_destruction.test.ts @@ -1,13 +1,47 @@ import { describe, expect, test } from "bun:test"; -import { bunRun, isBroken } from "harness"; +import { bunEnv, bunExe, bunRun } from "harness"; import { join } from "path"; describe("Worker destruction", () => { const method = ["Bun.connect", "Bun.listen", "fetch"]; describe.each(method)("bun when %s is used in a Worker that is terminating", method => { - // fetch: ASAN failure - test.concurrent.skipIf(isBroken && method == "fetch")("exits cleanly", async () => { + test.concurrent("exits cleanly", async () => { expect(await bunRun([join(import.meta.dir, "worker_thread_check.ts"), method])).toSpawn(); }); }); + + // The worker owns a child process whose stdin pipe has a large write in flight that can never + // complete (the child never reads). Terminating the worker must close that pipe through its owner + // rather than wait for the write; otherwise the worker thread never finishes and terminate() hangs. + test.concurrent("terminate() a Worker with a child process and a pending stdin write", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Worker } = require("worker_threads"); + const w = new Worker(\` + const { parentPort } = require("worker_threads"); + const p = Bun.spawn({ cmd: [process.execPath, "-e", "setInterval(() => {}, 1000)"], stdin: "pipe", stdout: "ignore", stderr: "ignore" }); + p.stdin.write(Buffer.alloc(4 << 20)); + p.stdin.flush(); + parentPort.postMessage(p.pid); + \`, { eval: true }); + w.on("error", e => { console.error(e); process.exit(2); }); + w.on("message", async pid => { + const code = await w.terminate(); + try { process.kill(pid); } catch {} + console.log("terminated " + code); + process.exit(0); + }); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout.trim()).toBe("terminated 1"); + expect(exitCode).toBe(0); + }); }); diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index aa3bf5d714d8..543786efcd11 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, setDefaultTimeout, test } from "bun:test"; -import { bunEnv, bunExe, isDebug, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isDebug, tempDir, tmpdirSync } from "harness"; import { once } from "node:events"; import fs from "node:fs"; import { join, relative, resolve } from "node:path"; @@ -532,6 +532,15 @@ describe("worker event", () => { }); }); +test("terminate() of a running, idle worker resolves 1 like Node", async () => { + const worker = new Worker( + `const { parentPort } = require("worker_threads"); parentPort.on("message", () => {}); parentPort.postMessage("ready");`, + { eval: true }, + ); + await once(worker, "message"); + expect(await worker.terminate()).toBe(1); +}); + describe("environmentData", () => { test("can pass a value to a child", async () => { setEnvironmentData("foo", new Map([["hello", "world"]])); @@ -630,14 +639,11 @@ describe("getHeapSnapshot", () => { // uncaught_exception return handled=true so spin() continues to // fireEarlyMessages (the call resolves with real data). Under `bun -e` // it rejects — see the test-worker-heapdump-failure.js vendored test for - // subprocess coverage. The two cases below take the shutdown() path - // directly so they exercise the m_pendingTasks abandon drain regardless. - test.each([ - ["entry not found", undefined], - ["unsettled top-level await", "await new Promise(() => {})"], - ])("rejects ERR_WORKER_NOT_RUNNING when called before a worker that fails to start (%s)", async (_, src) => { - const worker = - src === undefined ? new Worker("/nonexistent/__bun_worker_path__.js") : new Worker(src, { eval: true }); + // subprocess coverage. A worker whose entry is not found takes the + // shutdown() path directly, so it exercises the m_pendingTasks abandon drain + // regardless. + test("rejects ERR_WORKER_NOT_RUNNING when called before a worker that fails to start", async () => { + const worker = new Worker("/nonexistent/__bun_worker_path__.js"); worker.on("error", () => {}); // Called immediately (m_state still Pending) so the task queues into // m_pendingTasks; dispatchExit drains it on the parent thread when the @@ -941,6 +947,42 @@ test("MessagePort.hasRef() reports actual loop-ref state", () => { port1.close(); }); +// In a node worker only parentPort receives what the parent posts; the global +// scope's `self.onmessage` is not a channel there (as in node). Libraries that +// install both a parentPort listener and self.onmessage as a node/web shim +// must see one delivery, not two. +test("a parent message reaches parentPort only, not self.onmessage, in a node worker", async () => { + const w = new Worker( + `const { parentPort } = require("node:worker_threads"); + let count = 0; + parentPort.on("message", () => { count++; }); + self.onmessage = () => { count += 100; }; + parentPort.on("message", () => setImmediate(() => parentPort.postMessage(count)));`, + { eval: true }, + ); + w.postMessage("x"); + const [count] = await once(w, "message"); + await w.terminate(); + expect(count).toBe(1); +}); + +// node's setupPortReferencing tracks 'message' listeners only: a 'messageerror' +// handler alone neither starts the port nor keeps the loop alive. +test("onmessageerror alone does not ref the port", () => { + const { port1 } = new MessageChannel(); + port1.onmessageerror = () => {}; + const errorOnly = port1.hasRef(); + port1.onmessage = () => {}; + const withMessage = port1.hasRef(); + port1.onmessage = null; + expect({ errorOnly, withMessage, afterClearingMessage: port1.hasRef() }).toEqual({ + errorOnly: false, + withMessage: true, + afterClearingMessage: false, + }); + port1.close(); +}); + // Collecting the unreferenced peer must not look like a peer close: node never // closes a channel because a port was garbage-collected, so ref() still works. test("hasRef() survives collection of the unreferenced peer", () => { @@ -1774,3 +1816,732 @@ test("the SHARE_ENV founding thread's process.env stays live after the swap", as expect(stdout.trim()).toBe("yes,unset"); expect(exitCode).toBe(0); }); + +test("terminating a worker stops the workers it spawned", async () => { + // The leaf heartbeats to the main thread over a MessagePort routed through the + // middle worker. Terminating the middle worker must stop the leaf, which the main + // thread observes as its end of the channel closing. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Worker, MessageChannel } = require("worker_threads"); + const { port1, port2 } = new MessageChannel(); + const middle = new Worker( + \`const { Worker, workerData, parentPort } = require("worker_threads"); + const leaf = new Worker( + 'const { workerData } = require("worker_threads");' + + 'setInterval(() => workerData.port.postMessage("beat"), 5);', + { eval: true, workerData: { port: workerData.port }, transferList: [workerData.port] }); + leaf.on("online", () => parentPort.postMessage("leaf-online"));\`, + { eval: true, workerData: { port: port2 }, transferList: [port2] }, + ); + let beats = 0; + port1.on("message", () => { beats++; }); + middle.on("message", async m => { + if (m !== "leaf-online") return; + while (beats === 0) await new Promise(r => setImmediate(r)); + port1.on("close", () => { + console.log("leaf port closed"); + port1.close(); + }); + await middle.terminate(); + }); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout.trim()).toBe("leaf port closed"); + expect(exitCode).toBe(0); +}); + +// parentPort is a real MessagePort entangled with the parent Worker's public +// port, so it follows Node's lifecycle: a 'message' listener keeps the thread +// alive, and close()/unref() let it exit. +test("parentPort.close() ends a worker that is only listening for messages", async () => { + const w = new Worker( + `const { parentPort } = require("worker_threads"); + parentPort.on("message", m => { parentPort.postMessage("got " + m); if (m === "close") parentPort.close(); });`, + { eval: true }, + ); + const messages: string[] = []; + w.on("message", m => messages.push(m)); + const exited = new Promise(resolve => w.on("exit", resolve)); + w.postMessage("hello"); + w.postMessage("close"); + expect(await exited).toBe(0); + expect(messages).toEqual(["got hello", "got close"]); +}); + +test("parentPort.unref() lets a listening worker exit", async () => { + const w = new Worker( + `const { parentPort } = require("worker_threads"); + parentPort.on("message", () => {}); + parentPort.unref();`, + { eval: true }, + ); + const exited = new Promise(resolve => w.on("exit", resolve)); + expect(await exited).toBe(0); +}); + +test("receiveMessageOnPort distinguishes an undefined message from an empty queue", () => { + const { port1, port2 } = new MessageChannel(); + port1.postMessage(undefined); + port1.postMessage(0); + expect(receiveMessageOnPort(port2)).toEqual({ message: undefined }); + expect(receiveMessageOnPort(port2)).toEqual({ message: 0 }); + expect(receiveMessageOnPort(port2)).toBeUndefined(); + port1.close(); + port2.close(); +}); + +// A message the parent posts at construction is delivered only after the +// worker's entry module has evaluated (Node's ordering). Delivered early, an +// uncaught throw from the listener raced the still-loading entry and the exit +// handler's exitCode was overwritten. +test("parent messages are delivered after the worker's entry evaluated; exit handler's exitCode wins", async () => { + const w = new Worker( + `const { parentPort } = require("worker_threads"); + parentPort.once("message", () => { + process.on("exit", () => { process.exitCode = 0; }); + throw new Error("ok"); + });`, + { eval: true }, + ); + const errors: string[] = []; + w.on("error", e => errors.push(e.message)); + const exited = new Promise(resolve => w.on("exit", resolve)); + w.postMessage(0); + expect(await exited).toBe(0); + expect(errors).toEqual(["ok"]); +}); + +// node: assigning a non-function to parentPort.onmessage clears the handler and +// releases the ref the previous handler took, so the worker can exit. +test("parentPort.onmessage = lets the worker exit", async () => { + const w = new Worker( + `const { parentPort } = require("worker_threads"); + parentPort.onmessage = () => { throw new Error("must not be called"); }; + parentPort.onmessage = "fhqwhgads";`, + { eval: true }, + ); + const exited = new Promise(resolve => w.on("exit", resolve)); + w.postMessage(2); + expect(await exited).toBe(0); +}); + +// #15408: a worker whose top-level await has not settled is started (Node) — +// its parentPort listener registered before the await receives messages, and +// the await keeps running in the normal event loop. +test("parentPort messages are delivered while a top-level await is pending", async () => { + const w = new Worker( + `import { parentPort } from "worker_threads"; + parentPort.on("message", m => { parentPort.postMessage("got " + m); if (m === "bye") process.exit(0); }); + await new Promise(() => {});`, + { eval: true }, + ); + const replies: string[] = []; + w.on("message", m => { + replies.push(m); + if (m === "got hi") w.postMessage("bye"); + }); + const exited = new Promise(resolve => w.on("exit", resolve)); + w.postMessage("hi"); + expect(await exited).toBe(0); + expect(replies).toEqual(["got hi", "got bye"]); +}); + +// A top-level await that rejects while other work keeps the loop alive fails the +// worker at rejection time (Node), not when the loop eventually drains. +// (Subprocess: inside `bun test` a worker's uncaught error counts as handled.) +test("a top-level await rejecting while the loop is alive fails the worker then", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker } = require("worker_threads"); + const w = new Worker( + 'setInterval(() => {}, 1000); await new Promise((_, reject) => setTimeout(() => reject(new Error("late")), 5));', + { eval: true }, + ); + w.on("error", e => console.log("error: " + e.message)); + w.on("exit", c => console.log("exit " + c));`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("error: late\nexit 1\n"); + expect(exitCode).toBe(0); +}); + +// Static imports that are still being read/transpiled are loading, not a +// top-level await: 'online' and message delivery wait for the graph to execute. +test("a file worker's static imports load before it counts as started", async () => { + using dir = tempDir("worker-static-import-start", { + "dep.js": `export const listeners = [];\n${"// filler\n".repeat(2000)}`, + "w.js": `import { listeners } from "./dep.js"; +import { parentPort } from "worker_threads"; +parentPort.on("message", m => parentPort.postMessage("got " + m + " " + listeners.length));`, + }); + const w = new Worker(join(String(dir), "w.js")); + const reply = new Promise(resolve => w.on("message", resolve)); + w.postMessage("hi"); + expect(await reply).toBe("got hi 0"); + await w.terminate(); +}); + +// ─── worker teardown vs. work still in flight ──────────────────────────────── +// Each of these terminates a worker (or exits the process) while some off-thread +// or cross-thread work of that worker is still pending. They exercise the +// refusal / wait paths of VM teardown; a broken build crashes or trips ASAN +// rather than failing an assertion. +describe("terminate with work in flight", () => { + test("a transpile queued on the thread pool that starts after terminate()", async () => { + using dir = tempDir("worker-terminate-transpile", { + // large enough that the pool job is still queued/running at terminate + "big.ts": Array.from({ length: 4000 }, (_, i) => `export const v${i}: number = ${i};`).join("\n"), + "w.js": `require("worker_threads").parentPort.postMessage("go"); import("./big.ts").then(() => {});`, + }); + for (let i = 0; i < 8; i++) { + const w = new Worker(join(String(dir), "w.js")); + await new Promise(r => w.once("message", r)); + expect(await w.terminate()).toBe(1); + } + }); + + test("a SubtleCrypto digest still on the work queue at terminate()", async () => { + for (let i = 0; i < 4; i++) { + const w = new Worker( + `const { parentPort } = require("worker_threads"); + crypto.subtle.digest("SHA-256", new Uint8Array(64 << 20)).then(() => {}); + parentPort.postMessage("go");`, + { eval: true }, + ); + await new Promise(r => w.once("message", r)); + expect(await w.terminate()).toBe(1); + } + }); + + test("an async zlib job on the thread pool at terminate()", async () => { + for (let i = 0; i < 4; i++) { + const w = new Worker( + `const { parentPort } = require("worker_threads"); + const zlib = require("zlib"); + const buf = Buffer.alloc(32 << 20, "a"); + zlib.deflate(buf, () => {}); + zlib.brotliCompress(buf.subarray(0, 4 << 20), () => {}); + parentPort.postMessage("go");`, + { eval: true }, + ); + await new Promise(r => w.once("message", r)); + expect(await w.terminate()).toBe(1); + } + }); + + test("a fetch whose body is still streaming at terminate(), then process exit", async () => { + // Subprocess: the exiting main thread must not touch the dead worker's fetch. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker } = require("worker_threads"); + const server = Bun.serve({ + port: 0, + fetch() { + // never-ending chunked body + return new Response(new ReadableStream({ pull(c) { c.enqueue(new Uint8Array(1024)); return Bun.sleep(5); } })); + }, + }); + const w = new Worker( + 'const { parentPort, workerData } = require("worker_threads");' + + 'fetch(workerData).then(async r => { const rd = r.body.getReader(); await rd.read(); parentPort.postMessage("streaming"); for (;;) await rd.read(); });', + { eval: true, workerData: "http://127.0.0.1:" + server.port + "/" }, + ); + w.once("message", async () => { + await w.terminate(); + server.stop(true); + console.log("exiting"); + process.exit(0); + });`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("exiting\n"); + expect(exitCode).toBe(0); + }); + + test("the main thread exits while a worker is mid-way through sqlite statements", async () => { + using dir = tempDir("worker-sqlite-main-exit", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker } = require("worker_threads"); + const w = new Worker( + 'const { DatabaseSync } = require("node:sqlite"); const { Database } = require("bun:sqlite");' + + 'const a = new DatabaseSync("a.db"); a.exec("PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS t (x)");' + + 'const b = new Database("b.db"); b.run("PRAGMA journal_mode=WAL"); b.run("CREATE TABLE IF NOT EXISTS t (x)");' + + 'const ins = a.prepare("INSERT INTO t VALUES (?)");' + + 'require("worker_threads").parentPort.postMessage("busy");' + + 'for (let i = 0; ; i++) { ins.run(i); b.run("INSERT INTO t VALUES (?)", [i]); }', + { eval: true }, + ); + // Posted right before the worker enters its endless insert loop. + w.once("message", () => process.exit(0));`, + ], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "inherit", + }); + expect(await proc.exited).toBe(0); + }); + + test("a fetch still in flight when the main thread exits", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + // The server never responds; exit once both requests have reached it. + `let seen = 0; + const server = Bun.serve({ port: 0, fetch: () => { if (++seen === 2) { console.log("exiting"); process.exit(0); } return new Promise(() => {}); } }); + fetch("http://127.0.0.1:" + server.port + "/").catch(() => {}); + fetch("http://127.0.0.1:" + server.port + "/").catch(() => {});`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("exiting\n"); + expect(exitCode).toBe(0); + }); +}); + +// A JS preload's modules are not the entry: the worker counts as started (online, +// parent messages delivered) only once its own entry graph has executed. +test("a worker with a preload is not started before its entry module runs", async () => { + using dir = tempDir("worker-preload-start", { + "setup.js": `globalThis.setupRan = true;`, + "dep.js": `export const dep = 1;\n${"// filler\n".repeat(3000)}`, + "w.mjs": `import { dep } from "./dep.js"; +import { parentPort } from "worker_threads"; +parentPort.on("message", m => parentPort.postMessage(["got", m, dep, globalThis.setupRan === true]));`, + }); + const w = new Worker(join(String(dir), "w.mjs"), { preload: join(String(dir), "setup.js") }); + const reply = new Promise(resolve => w.on("message", resolve)); + w.postMessage("hi"); + expect(await reply).toEqual(["got", "hi", 1, true]); + await w.terminate(); +}); + +// Releasing the last keep-alive from an immediate (after the tick, before the +// poll) must be noticed before the loop parks. +test("closing the only ref'd port from setImmediate lets the process exit", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { port1 } = new MessageChannel(); + port1.onmessage = () => {}; + setImmediate(() => { port1.close(); console.log("closed"); });`, + ], + // Without the idle GC timer nothing else would ever wake a parked loop. + env: { ...bunEnv, BUN_GC_TIMER_DISABLE: "1" }, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("closed\n"); + expect(exitCode).toBe(0); +}); + +// Node's setupPortReferencing: the parent side of parentPort keeps the parent +// alive while the Worker has 'message' listeners, independently of unref(). +test("an unref'ed worker with a 'message' listener still delivers to the parent", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker } = require("worker_threads"); + const w = new Worker('require("worker_threads").parentPort.postMessage("hello"); setTimeout(() => {}, 1000);', { eval: true }); + w.unref(); + w.on("message", m => { console.log(m); process.exit(0); });`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("hello\n"); + expect(exitCode).toBe(0); +}); + +// A Bun.build whose plugin never answers, in a worker that is terminated: the +// build is cancelled with the worker, and the process-wide bundle thread stays +// usable for the parent. +test("terminating a worker mid-Bun.build (plugin pending) does not wedge the bundler", async () => { + using dir = tempDir("worker-build-cancel", { + "entry.js": `import "./dep.js"; console.log("entry");`, + "dep.js": `console.log("dep");`, + "w.js": ` + const { parentPort } = require("worker_threads"); + Bun.build({ + entrypoints: ["./entry.js"], + // onLoad never answers; it tells the parent once the bundler is waiting on it. + plugins: [{ name: "hang", setup(b) { b.onLoad({ filter: /dep\\.js$/ }, () => { parentPort.postMessage("pending"); return new Promise(() => {}); }); } }], + }).then(() => parentPort.postMessage("built"), e => parentPort.postMessage("failed")); + `, + }); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker } = require("worker_threads"); + const w = new Worker("./w.js"); + w.once("message", async m => { + console.log("worker:", m); + await w.terminate(); + const out = await Bun.build({ entrypoints: ["./entry.js"] }); + console.log("parent build:", out.success, out.outputs.length > 0); + process.exit(0); + });`, + ], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("worker: pending\nparent build: true true\n"); + expect(exitCode).toBe(0); +}); + +// The worker gets its own copies of options.argv/execArgv strings (they live in +// the parent's WorkerOptions); empty strings included. +test("worker argv/execArgv option strings, read repeatedly in the worker", async () => { + const src = `const { parentPort } = require("node:worker_threads"); + for (let i = 0; i < 200; i++) { process.argv; process.execArgv } + parentPort.postMessage({ argv: process.argv.slice(2), execArgv: process.execArgv })`; + const ws = Array.from( + { length: 4 }, + (_, i) => new Worker(src, { eval: true, argv: ["", "a" + i, "\u00fc\u2603", ""], execArgv: ["", "--x"] }), + ); + const got = await Promise.all(ws.map(w => new Promise(res => w.once("message", res)))); + expect(got).toEqual([0, 1, 2, 3].map(i => ({ argv: ["", "a" + i, "\u00fc\u2603", ""], execArgv: ["", "--x"] }))); + await Promise.all(ws.map(w => w.terminate())); +}); + +// A build whose plugin answers slowly (async setup + async onLoad) is in every +// possible phase when the worker goes away; each must cancel, not wait on the +// worker's JS thread for an answer that will never come. +test("terminate()/exit while Bun.build with a slow plugin is mid-flight in the worker", async () => { + using dir = tempDir("worker-build-slow-plugin", { + "entry.ts": + Array.from({ length: 20 }, (_, i) => `export * as n${i} from "./m${i}.ts"`).join("\n") + + `\nimport data from "virtual:data"\nexport { data }\n`, + ...Object.fromEntries( + Array.from({ length: 20 }, (_, i) => [ + `m${i}.ts`, + `import { v as a } from "./m${(i + 1) % 20}.ts"\nexport const v: number = ${i}\nexport function f${i}(x: number) { return x + a }\n`, + ]), + ), + }); + const workerSrc = ` + import { join } from "node:path"; + const SRC = process.env.SRC, OUT = process.env.OUT; + const slow = { name: "slow", setup(build) { + build.onResolve({ filter: /^virtual:data$/ }, () => ({ path: "data", namespace: "virt" })); + build.onLoad({ filter: /.*/, namespace: "virt" }, async () => { await Bun.sleep(5 + Math.random() * 40); return { contents: "export default 1", loader: "js" } }); + return Bun.sleep(Math.random() * 30); + } }; + let n = 0; + const one = () => Bun.build({ entrypoints: [join(SRC, "entry.ts")], outdir: join(OUT, String(n++)), plugins: [slow] }); + self.onmessage = e => { if (e.data === "exit") process.exit(0) }; + let inflight = 0; + (function pump() { while (inflight < 3) { inflight++; Promise.resolve().then(one).catch(() => {}).finally(() => { inflight--; setImmediate(pump) }) } })(); + postMessage("busy"); + `; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const url = URL.createObjectURL(new Blob([${JSON.stringify(workerSrc)}])); + for (let r = 0; r < 6; r++) { + const door = r % 2 ? "exit" : "terminate"; + const ws = Array.from({ length: 1 + (r % 3) }, (_, i) => new Worker(url, { env: { ...process.env, OUT: process.env.OUT + "/r" + r + "w" + i } })); + await Promise.all(ws.map(w => new Promise(res => { w.onmessage = e => e.data === "busy" && res(); w.addEventListener("close", res) }))); + await Bun.sleep((r * 11) % 60); + const closed = ws.map(w => new Promise(res => w.addEventListener("close", res))); + for (const w of ws) { if (door === "terminate") w.terminate(); else w.postMessage("exit") } + await Promise.all(closed); + } + console.log("PASS");`, + ], + env: { ...bunEnv, SRC: String(dir), OUT: join(String(dir), "out") }, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("PASS\n"); + expect(exitCode).toBe(0); +}, 60_000); + +// The IPC channel belongs to the process; a worker in a forked child sees the +// inherited NODE_CHANNEL_FD but must not open a second endpoint on it (Node: +// process.send is undefined in worker threads). +test("a worker inside a process with an IPC channel has no process.send of its own", async () => { + using dir = tempDir("worker-no-ipc", { + "main.js": ` + if (process.argv[2] === "child") { + const { Worker } = require("node:worker_threads"); + const w = new Worker( + 'const { parentPort } = require("node:worker_threads"); parentPort.postMessage({ send: typeof process.send, connected: process.connected, channel: typeof process.channel });', + { eval: true }, + ); + w.once("message", m => { + process.send({ worker: m, main: { send: typeof process.send, connected: process.connected } }); + w.terminate().then(() => process.exit(0)); + }); + } else { + const { fork } = require("node:child_process"); + const child = fork(__filename, ["child"]); + child.on("message", m => console.log(JSON.stringify(m))); + child.on("exit", code => process.exit(code)); + } + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(JSON.parse(stdout.trim())).toEqual({ + worker: { send: "undefined", connected: false, channel: "undefined" }, + main: { send: "function", connected: true }, + }); + expect(exitCode).toBe(0); +}); + +describe("VM teardown ordering", () => { + // The exiting main thread must not park the process-wide HTTP thread while a + // child can still start a request: the child then waits for a hand-back that + // never comes and the parent waits for the child. + test("process.exit() while a worker keeps starting fetches", async () => { + using server = Bun.serve({ port: 0, fetch: () => new Response("ok") }); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker } = require("worker_threads"); + const w = new Worker( + 'const { workerData, parentPort } = require("worker_threads");' + + 'parentPort.postMessage("ready");' + + '(async () => { for (;;) { fetch(workerData.url).catch(() => {}); await 1; } })();', + { eval: true, workerData: { url: "${server.url.href}" } }); + w.once("message", () => setImmediate(() => process.exit(0)));`, + ], + env: bunEnv, + stdout: "ignore", + stderr: "inherit", + }); + expect(await proc.exited).toBe(0); + }); + + // A shell `cp` hands its copy to an fs.cp task on the pool; the pool part is + // over then, not when the JS-thread continuation runs. + test("process.exit() with a shell cp in flight", async () => { + const files: Record = {}; + for (let i = 0; i < 60; i++) files[`src/f${i}.bin`] = Buffer.alloc(512 * 1024, 120); + using dir = tempDir("exit-shell-cp", files); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const fs = require("fs"); + Bun.$\`cp -R src dst\`.then(() => {}); + // Exit once the copy is visibly under way. + (function poll() { + fs.existsSync("dst") && fs.readdirSync("dst").length > 0 ? process.exit(0) : setImmediate(poll); + })();`, + ], + env: { ...bunEnv, BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS: "1" }, + cwd: String(dir), + stdout: "ignore", + stderr: "inherit", + }); + expect(await proc.exited).toBe(0); + }); + + // An S3 upload aborted by its worker's teardown must not retry onto the + // closed VM: the retry would complete on the HTTP thread against a dead handle. + test("terminating a worker mid S3 upload does not retry onto the dead VM", async () => { + let first = true; + using server = Bun.serve({ + port: 0, + fetch: () => { + if (first) { + first = false; + return new Promise(() => {}); // the upload terminate() interrupts + } + return new Response("no", { status: 503 }); // any retry fails fast + }, + }); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker } = require("worker_threads"); + const w = new Worker( + 'const { workerData, parentPort } = require("worker_threads");' + + 'const s3 = new Bun.S3Client({ accessKeyId: "k", secretAccessKey: "s", bucket: "b", endpoint: workerData.url, retry: 3 });' + + // writer(): a MultiPartUpload, whose single-send failure path retries. + 'const wr = s3.file("key").writer({ retry: 3 }); wr.write(Buffer.alloc(1024 * 1024)); wr.end().catch(() => {});' + + 'parentPort.postMessage("uploading");', + { eval: true, workerData: { url: "${server.url.href}" } }); + w.once("message", async () => { + console.log("exit", await w.terminate()); + // Outlive any retry the HTTP thread would complete. + setTimeout(() => process.exit(0), 500); + });`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("exit 1\n"); + expect(exitCode).toBe(0); + }); +}); + +// A native completion on the worker's own loop (here: a dns lookup finishing) +// after the parent requested termination must not settle a promise with the +// empty value its interrupted JS conversion produced. +test("terminate() while dns lookups keep completing in the worker", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker } = require("worker_threads"); + const w = new Worker( + 'const dns = require("dns"); const { parentPort } = require("worker_threads");' + + 'let n = 0;' + + '(function go() { dns.lookup("localhost", () => {}); dns.promises.lookup("localhost").catch(() => {}); if (++n === 50) parentPort.postMessage("going"); setImmediate(go); })();', + { eval: true }); + w.once("message", async () => { console.log("exit", await w.terminate()); process.exit(0); });`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("exit 1\n"); + expect(exitCode).toBe(0); +}, 30_000); + +// What a worker's own handlers may observe of its stop, in what order. Every +// callback the worker could run appends a tag to a shared log the parent reads +// after the thread is gone, so ordering is checked from outside the dying VM. +// - terminate(): the stop is not the worker's choice — no 'exit' handler, no +// resource 'close'/'error' handler, nothing at all runs after the request. +// - process.exit() from inside a callback: 'exit' handlers run exactly once and +// are the last script the worker runs; resources are closed natively after +// that, so none of their handlers follow. +// In both the parent's loop returns to idle afterwards (nothing the worker held +// keeps it alive) — the test process exiting at all is that check. +describe("worker stop ordering as seen by the worker's own handlers", () => { + const TAG = { + exitHandler: 1, + serverClose: 2, + socketClose: 3, + socketError: 4, + udpClose: 5, + watcherClose: 6, + intervalTick: 7, + streamCancel: 8, + portClose: 9, + beforeExit: 10, + afterExitCall: 11, + ready: 12, + } as const; + const workerSource = (door: "terminate" | "exit") => ` + const { workerData, parentPort } = require("node:worker_threads"); + const log = workerData.log; + const put = tag => { const i = Atomics.add(log, 0, 1) + 1; if (i < log.length) Atomics.store(log, i, tag); }; + process.on("exit", () => put(${TAG.exitHandler})); + process.on("beforeExit", () => put(${TAG.beforeExit})); + const net = require("node:net"), dgram = require("node:dgram"), fs = require("node:fs"), os = require("node:os"); + const server = net.createServer(() => {}).listen(0, "127.0.0.1"); + server.on("close", () => put(${TAG.serverClose})); + const udp = dgram.createSocket("udp4"); udp.bind(0, "127.0.0.1"); udp.on("close", () => put(${TAG.udpClose})); + const watcher = fs.watch(os.tmpdir(), () => {}); watcher.on("close", () => put(${TAG.watcherClose})); + setInterval(() => put(${TAG.intervalTick}), 1).unref(); + const { port1, port2 } = new MessageChannel(); port1.on("message", () => {}); port1.on("close", () => put(${TAG.portClose})); globalThis.keepPeer = port2; + Bun.serve({ port: 0, development: false, fetch: () => new Response("x") }); + new ReadableStream({ pull() {}, cancel() { put(${TAG.streamCancel}); } }).getReader().read(); + server.on("listening", () => { + const sock = net.connect(server.address().port, "127.0.0.1"); + sock.on("close", () => put(${TAG.socketClose})); + sock.on("error", () => put(${TAG.socketError})); + sock.on("connect", () => { + put(${TAG.ready}); + parentPort.postMessage("ready"); + ${door === "exit" ? `parentPort.on("message", () => { process.exit(7); put(${TAG.afterExitCall}); });` : `parentPort.on("message", () => {});`} + }); + }); + `; + + async function run(door: "terminate" | "exit") { + const log = new Int32Array(new SharedArrayBuffer(4 * 256)); + const w = new Worker(workerSource(door), { eval: true, workerData: { log } }); + const errors: unknown[] = []; + w.on("error", e => errors.push(e)); + const exited = once(w, "exit").then(([code]) => code as number); + await once(w, "message"); // "ready": every resource is up + let code: number; + if (door === "terminate") { + const t = w.terminate(); + code = await exited; + // terminate() resolves the same code the 'exit' event carried. + expect(await t).toBe(code); + } else { + w.postMessage("go"); + code = await exited; + } + const n = Math.min(Atomics.load(log, 0), log.length - 1); + const tags = Array.from(log.slice(1, 1 + n)).filter(t => t !== TAG.intervalTick); + return { code, tags, errors }; + } + + test("terminate(): nothing of the worker's runs after the request", async () => { + const { code, tags, errors } = await run("terminate"); + expect(errors).toEqual([]); + // Only what ran before the parent asked: the "ready" marker. No 'exit' + // handler (not the worker's choice), no close/error/cancel handler. + expect(tags).toEqual([TAG.ready]); + expect(code).toBe(1); + }); + + test("process.exit() inside a callback: 'exit' handlers are the last script; no resource handler follows", async () => { + const { code, tags, errors } = await run("exit"); + expect(errors).toEqual([]); + // ready → the 'exit' handler once → nothing: the statement after + // process.exit() never runs, and closing the server/socket/udp/watcher/ + // port/stream natively afterwards dispatches none of their handlers. + expect(tags).toEqual([TAG.ready, TAG.exitHandler]); + expect(code).toBe(7); + }); +}); diff --git a/test/js/web/workers/worker-fixture-argv.js b/test/js/web/workers/worker-fixture-argv.js index 836c5c8a0614..a3f4016845fa 100644 --- a/test/js/web/workers/worker-fixture-argv.js +++ b/test/js/web/workers/worker-fixture-argv.js @@ -1,6 +1,9 @@ -(globalThis.addEventListener || require("node:worker_threads").parentPort.on)("message", () => { - const postMessage = globalThis.postMessage || require("node:worker_threads").parentPort.postMessage; - postMessage({ +// Loaded both as a Web Worker and as a node:worker_threads Worker; parentPort +// receives the parent's messages in both (in a node worker only parentPort +// does, as in node). +const { parentPort } = require("node:worker_threads"); +parentPort.on("message", () => { + parentPort.postMessage({ argv: process.argv, execArgv: process.execArgv, }); diff --git a/test/js/web/workers/worker-refused-completion.test.ts b/test/js/web/workers/worker-refused-completion.test.ts new file mode 100644 index 000000000000..c09f56f9ac97 --- /dev/null +++ b/test/js/web/workers/worker-refused-completion.test.ts @@ -0,0 +1,147 @@ +// When a worker is gone by the time another thread finishes work the worker +// started, that thread's completion is refused at the worker's VM handle and +// the producer releases what it owns itself. Here that path runs +// deterministically for one producer at a time: with +// BUN_DEBUG_TEST_WORKER_REFUSAL_GATE the completion waits for the worker's +// handle to close and is then refused, and the runtime names each refusal on +// stderr. A row passes only if the named refusal happened (the work really was +// on another thread and its release path ran) and the process exited cleanly; +// on the ASAN build the release path is also checked for use-after-free and +// leaks. Builds with debug assertions only (debug, ASAN): the gate does not +// exist in release builds. +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isASAN, isDebug, isWindows } from "harness"; + +type Row = { + name: string; + // Runs in the worker before it exits; starts exactly one piece of off-thread work. + worker: string; + // Substring of the refusal the runtime must log for it. + refused: string; + // Runs in the parent once the worker says it is set up (for producers on the parent's side). + parent?: string; + env?: Record; + skip?: boolean; +}; + +const ROWS: Row[] = [ + { + name: "fs.readFile", + worker: `require("node:fs").readFile(process.execPath, () => {});`, + refused: "args::ReadFile", + }, + { name: "fs.promises.stat", worker: `require("node:fs").promises.stat(process.execPath);`, refused: "args::Stat" }, + { + name: "fs.realpath", + worker: `require("node:fs").realpath(process.execPath, () => {});`, + refused: "args::Realpath", + }, + { + name: "Bun.file().text()", + worker: `Bun.file(process.execPath).slice(0, 65536).text();`, + refused: "blob::read_file::ReadFile", + }, + { + name: "crypto.pbkdf2", + worker: `require("node:crypto").pbkdf2("p", "s", 1000, 32, "sha256", () => {});`, + refused: "Pbkdf2Job", + }, + { name: "crypto.scrypt", worker: `require("node:crypto").scrypt("p", "s", 32, () => {});`, refused: "ScryptJob" }, + { + name: "crypto.randomFill", + worker: `require("node:crypto").randomFill(Buffer.alloc(65536), () => {});`, + refused: "RandomFillJob", + }, + { + name: "crypto.generateKeyPair", + worker: `require("node:crypto").generateKeyPair("ec", { namedCurve: "P-256" }, () => {});`, + refused: "EcKeyPairJob", + }, + { + name: "crypto.subtle.digest", + worker: `crypto.subtle.digest("SHA-256", Buffer.alloc(65536));`, + refused: "refused post: CppTask", + }, + { + name: "Bun.password.hash", + worker: `Bun.password.hash("x", { algorithm: "bcrypt", cost: 4 });`, + refused: "PasswordJob", + }, + { + name: "Bun.Glob scan", + worker: `new Bun.Glob("*").scan({ cwd: require("node:os").tmpdir() })[Symbol.asyncIterator]().next();`, + refused: "glob::WalkTask", + }, + { + name: "dns lookup on the thread pool", + worker: `Bun.dns.lookup("localhost", { backend: "libc" });`, + refused: "get_addr_info_request::LibcLookup", + }, + { + name: "child exit reported by the waiter thread", + worker: `require("node:child_process").execFile(process.execPath, ["-e", "0"], () => {});`, + refused: "ProcessWaiterThreadTask", + // The waiter thread is a POSIX fallback path, opted into here the way the runtime's own tests do. + env: { BUN_GARBAGE_COLLECTOR_LEVEL: "0", BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" }, + skip: isWindows, + }, + { + name: "BroadcastChannel message from another thread", + worker: `const bc = new BroadcastChannel("wrc"); bc.onmessage = () => {};`, + // An open channel keeps the parent's loop alive (as in Node); close it once posted. + parent: `const pc = new BroadcastChannel("wrc"); pc.postMessage("late"); pc.close();`, + refused: "refused post: CppTask", + }, + { + name: "MessagePort message from another thread", + worker: `workerData.port.on("message", () => {});`, + parent: `port1.postMessage("late");`, + refused: "refused post: CppTask", + }, +]; + +// The host: one worker, armed gate. The worker starts its work, reports +// "armed" and exits by itself two turns later; the parent never posts anything +// the worker waits for (with the gate armed, a parent→worker post is itself a +// cross-thread completion that waits for the worker's close). Rows with a +// parent side post in response to "armed": that post is what gets refused. +function host(row: Row) { + return ` + const { Worker, MessageChannel } = require("node:worker_threads"); + const { port1, port2 } = new MessageChannel(); + const w = new Worker(\` + const { parentPort, workerData } = require("node:worker_threads"); + parentPort.on("message", () => {}); + ${row.worker.replace(/`/g, "\\`").replace(/\$\{/g, "\\${")} + parentPort.postMessage("armed"); + setImmediate(() => setImmediate(() => process.exit(0))); + \`, { eval: true, workerData: { port: port2 }, transferList: [port2] }); + w.on("error", e => { console.error("worker error:", e && e.message); process.exitCode = 1; }); + w.once("message", () => { ${row.parent ?? ""} }); + w.on("exit", code => { port1.close(); if (code !== 0) { console.error("worker exit code", code); process.exitCode = 1; } }); + `; +} + +describe.skipIf(!isDebug && !isASAN)( + "a completion for a worker that is gone is refused and released by its producer", + () => { + for (const row of ROWS) { + test.concurrent.skipIf(!!row.skip)(row.name, async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", host(row)], + env: { ...bunEnv, ...row.env, BUN_DEBUG_TEST_WORKER_REFUSAL_GATE: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const refusals = stderr.split("\n").filter(l => l.startsWith("[vm_handle] refused ")); + expect({ + exitCode, + refused: refusals.some(l => l.includes(row.refused)), + // On a failure, everything the host printed. + detail: exitCode === 0 && refusals.some(l => l.includes(row.refused)) ? "" : stdout + stderr, + }).toEqual({ exitCode: 0, refused: true, detail: "" }); + }); + } + }, +); diff --git a/test/js/web/workers/worker-terminate-funnels-fixture.ts b/test/js/web/workers/worker-terminate-funnels-fixture.ts new file mode 100644 index 000000000000..6aab598adcff --- /dev/null +++ b/test/js/web/workers/worker-terminate-funnels-fixture.ts @@ -0,0 +1,820 @@ +// Host for worker-terminate-funnels.test.ts. Runs one family of entries: for each +// (entry, phase) it starts a worker that arms one native→JS entry point, and the +// parent terminates the worker at a fixed point relative to that entry point: +// +// armed – the callback is armed but cannot have fired (its trigger is withheld) +// inside – the callback signals and parks on a shared cell; the parent +// terminates, then releases it, so the stop is requested while JS is +// inside that callback and takes effect at its next safepoint +// after – the callback re-arms the same operation, the parent supplies the +// trigger for it and terminates: the follow-on completion is in +// flight when the worker stops +// +// Every case must end with 'exit' and nothing else. Prints "ok ". +import { Worker } from "node:worker_threads"; +import net from "node:net"; +import dgram from "node:dgram"; + +type Phase = "armed" | "inside" | "after"; +type Handle = { params?: any; onSignal?: () => void | Promise; close?: () => void }; +type Entry = { + name: string; + phases: Phase[]; + // Body of the worker. Has `phase`, `params`, `armed()`, `hit(rearm?)` in scope. + worker: string; + // Parent-side peer for this case (a server to connect to, a port to post to…). + host?: (phase: Phase, worker: () => Worker) => Handle | Promise; +}; + +const ALL: Phase[] = ["armed", "inside", "after"]; + +const PRELUDE = ` + const { workerData, parentPort } = require("node:worker_threads"); + const { ctl, phase, params } = workerData; + function signal() { Atomics.store(ctl, 0, 1); Atomics.notify(ctl, 0); } + // Once the entry point is armed (and, for "armed", cannot fire). + function armed() { if (phase === "armed") signal(); } + // First line of the entry point's callback. + function hit(rearm) { + if (phase === "inside") { signal(); Atomics.wait(ctl, 1, 0); } + else if (phase === "after") { if (rearm) rearm(); signal(); } + } +`; + +const FAMILIES: Record = { + timers: [ + { + name: "setTimeout", + phases: ALL, + worker: `setTimeout(() => hit(() => setTimeout(() => {}, 0)), phase === "armed" ? 1e8 : 0); armed();`, + }, + { + name: "setInterval", + phases: ALL, + worker: `let n = 0; const t = setInterval(() => { if (n++ === 0) hit(); }, phase === "armed" ? 1e8 : 1); armed();`, + }, + { + name: "setImmediate", + phases: ["inside", "after"], + worker: `setImmediate(() => hit(() => setImmediate(() => {})));`, + }, + { + name: "promise reaction", + phases: ["inside", "after"], + worker: `Promise.resolve().then(() => hit(() => Promise.resolve().then(() => {})));`, + }, + { + name: "process.nextTick", + phases: ["inside", "after"], + worker: `process.nextTick(() => hit(() => process.nextTick(() => {})));`, + }, + // The timeout's own timer is unref'd (as in Node); the port listener keeps the worker up until it fires. + { + name: "AbortSignal.timeout", + phases: ALL, + worker: `parentPort.on("message", () => {}); AbortSignal.timeout(phase === "armed" ? 1e8 : 1).addEventListener("abort", () => hit()); armed();`, + }, + { name: "keep-alive only", phases: ["armed"], worker: `parentPort.on("message", () => {}); armed();` }, + ], + + messaging: [ + { + name: "parentPort message", + phases: ALL, + worker: `parentPort.on("message", () => hit()); armed(); if (phase !== "armed") parentPort.postMessage("ready");`, + host: (phase, worker) => ({ + // Deliver only once the listener exists; for "after", a second message is in flight at stop. + params: undefined, + onSignal: () => { + if (phase === "after") worker().postMessage(2); + }, + }), + }, + { + name: "MessageChannel delivery", + phases: ALL, + worker: ` + const { port1, port2 } = new MessageChannel(); + port1.on("message", () => hit(() => port2.postMessage(2))); + armed(); + if (phase !== "armed") port2.postMessage(1); + `, + }, + { + name: "transferred port delivery", + phases: ALL, + worker: ` + params.port.on("message", () => hit()); + params.port.postMessage("sub"); // tells the parent the listener exists + armed(); + `, + host: phase => { + const { port1, port2 } = new MessageChannel(); + port1.on("message", () => { + if (phase !== "armed") port1.postMessage(1); + }); + return { + params: { port: port2 }, + transfer: [port2], + onSignal: () => { + if (phase === "after") port1.postMessage(2); + }, + close: () => port1.close(), + } as any; + }, + }, + { + name: "BroadcastChannel delivery", + phases: ALL, + worker: ` + const bc = new BroadcastChannel(params.name); + bc.onmessage = () => hit(); + armed(); + if (phase !== "armed") new BroadcastChannel(params.name + "-w").postMessage("sub"); + `, + host: phase => { + const name = "wtf-" + Math.random().toString(36).slice(2); + const bc = new BroadcastChannel(name); + const sub = new BroadcastChannel(name + "-w"); + sub.onmessage = () => bc.postMessage(1); + return { + params: { name }, + onSignal: () => { + if (phase === "after") bc.postMessage(2); + }, + close: () => { + bc.close(); + sub.close(); + }, + }; + }, + }, + { + name: "messages posted before natural exit", + phases: ["armed"], + // Not terminated: the worker floods and exits by itself; the parent must get every message then 'exit'. + worker: `for (let i = 0; i < 3000; i++) parentPort.postMessage(i);`, + host: () => ({ natural: 3000 }) as any, + }, + ], + + net: [ + { + name: "net.Socket data", + phases: ALL, + worker: ` + const s = require("node:net").connect(params.port, "127.0.0.1"); + s.on("data", () => hit()); + s.on("error", () => {}); + s.on("connect", () => armed()); + `, + host: phase => tcpPeer(phase, { writeOnOpen: phase !== "armed", writeOnSignal: phase === "after" }), + }, + { + name: "net.Socket end", + phases: ["armed", "inside"], + worker: ` + const s = require("node:net").connect(params.port, "127.0.0.1"); + s.on("data", () => {}); s.on("end", () => hit()); s.on("error", () => {}); + s.on("connect", () => armed()); + `, + host: phase => tcpPeer(phase, { endOnOpen: phase !== "armed" }), + }, + { + name: "net.Server connection", + phases: ALL, + worker: ` + const srv = require("node:net").createServer(c => { hit(); c.destroy(); }); + srv.listen(0, "127.0.0.1", () => { parentPort.postMessage(srv.address().port); armed(); }); + `, + host: (phase, worker) => { + let port = 0; + const connect = () => { + const c = net.connect(port, "127.0.0.1"); + c.on("error", () => {}); + c.on("connect", () => c.end()); + }; + return { + onPort: (p: number) => { + port = p; + if (phase !== "armed") connect(); + }, + onSignal: () => { + if (phase === "after") connect(); + }, + } as any; + }, + }, + { + name: "Bun.connect data", + phases: ALL, + worker: ` + Bun.connect({ hostname: "127.0.0.1", port: params.port, socket: { + open() { armed(); }, data() { hit(); }, error() {}, close() {}, + }}).catch(() => {}); + `, + host: phase => tcpPeer(phase, { writeOnOpen: phase !== "armed", writeOnSignal: phase === "after" }), + }, + { + name: "Bun.listen open", + phases: ALL, + worker: ` + const l = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { open(s) { hit(); s.end(); }, data() {}, error() {} } }); + parentPort.postMessage(l.port); armed(); + `, + host: phase => { + let port = 0; + const connect = () => { + const c = net.connect(port, "127.0.0.1"); + c.on("error", () => {}); + c.on("connect", () => c.end()); + }; + return { + onPort: (p: number) => { + port = p; + if (phase !== "armed") connect(); + }, + onSignal: () => { + if (phase === "after") connect(); + }, + } as any; + }, + }, + { + name: "dgram message", + phases: ALL, + worker: ` + const s = require("node:dgram").createSocket("udp4"); + s.on("message", () => hit()); + s.bind(0, "127.0.0.1", () => { parentPort.postMessage(s.address().port); armed(); }); + `, + host: phase => { + const c = dgram.createSocket("udp4"); + let port = 0; + return { + onPort: (p: number) => { + port = p; + if (phase !== "armed") c.send("x", port, "127.0.0.1"); + }, + onSignal: () => { + if (phase === "after") c.send("y", port, "127.0.0.1"); + }, + close: () => c.close(), + } as any; + }, + }, + ], + + http: [ + { + name: "http.Server request", + phases: ALL, + worker: ` + const srv = require("node:http").createServer((req, res) => { hit(); res.end("x"); }); + srv.listen(0, "127.0.0.1", () => { parentPort.postMessage(srv.address().port); armed(); }); + `, + host: phase => { + let port = 0; + const get = () => + fetch("http://127.0.0.1:" + port) + .then(r => r.text()) + .catch(() => {}); + return { + onPort: (p: number) => { + port = p; + if (phase !== "armed") get(); + }, + onSignal: () => { + if (phase === "after") get(); + }, + } as any; + }, + }, + { + name: "http.ClientRequest response data", + phases: ALL, + worker: ` + require("node:http").get("http://127.0.0.1:" + params.port + "/" + phase, res => { res.on("data", () => hit()); res.on("error", () => {}); }) + .on("error", () => {}).on("socket", () => armed()); + `, + host: phase => httpPeer(phase), + }, + { + name: "fetch settle + body pull", + phases: ALL, + worker: ` + fetch("http://127.0.0.1:" + params.port + "/" + phase).then(async r => { + const reader = r.body.getReader(); + hit(() => reader.read().catch(() => {})); + await reader.read().catch(() => {}); + }).catch(() => {}); + armed(); + `, + host: phase => httpPeer(phase), + }, + { + name: "Bun.serve fetch handler", + phases: ALL, + worker: ` + const srv = Bun.serve({ port: 0, hostname: "127.0.0.1", development: false, fetch() { hit(); return new Response("x"); } }); + parentPort.postMessage(srv.port); armed(); + `, + host: phase => { + let port = 0; + const get = () => + fetch("http://127.0.0.1:" + port) + .then(r => r.text()) + .catch(() => {}); + return { + onPort: (p: number) => { + port = p; + if (phase !== "armed") get(); + }, + onSignal: () => { + if (phase === "after") get(); + }, + } as any; + }, + }, + { + name: "WebSocket client message", + phases: ALL, + worker: ` + const ws = new WebSocket("ws://127.0.0.1:" + params.port + "/" + phase); + ws.onopen = () => armed(); + ws.onmessage = () => hit(() => ws.send("more")); + ws.onerror = () => {}; + `, + host: async phase => { + const srv = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + development: false, + fetch(req, s) { + return s.upgrade(req) ? undefined : new Response("no"); + }, + websocket: { + open(ws) { + if (phase !== "armed") ws.send("x"); + }, + message(ws) { + ws.send("y"); + }, + }, + }); + return { params: { port: srv.port }, close: () => srv.stop(true) }; + }, + }, + { + name: "ServerWebSocket message", + phases: ALL, + worker: ` + const srv = Bun.serve({ port: 0, hostname: "127.0.0.1", development: false, + fetch(req, s) { return s.upgrade(req) ? undefined : new Response("no"); }, + websocket: { open() { armed(); }, message(ws) { hit(); ws.send("y"); } } }); + parentPort.postMessage(srv.port); + `, + host: phase => { + let ws: WebSocket | undefined; + return { + onPort: (p: number) => { + ws = new WebSocket("ws://127.0.0.1:" + p); + ws.onerror = () => {}; + ws.onopen = () => { + if (phase !== "armed") ws!.send("x"); + }; + }, + onSignal: () => { + if (phase === "after") ws?.send("z"); + }, + close: () => ws?.close(), + } as any; + }, + }, + ], + + fs: [ + { + name: "fs.readFile", + phases: ["inside", "after"], + worker: `const fs = require("node:fs"); fs.readFile(process.execPath, () => hit(() => fs.readFile(process.execPath, () => {})));`, + }, + { + name: "fs.promises.stat", + phases: ["inside", "after"], + worker: `const fs = require("node:fs"); fs.promises.stat(process.execPath).then(() => hit(() => fs.promises.stat(process.execPath)));`, + }, + { + name: "fs.createReadStream data", + phases: ["inside", "after"], + worker: `const rs = require("node:fs").createReadStream(process.execPath, { highWaterMark: 1 << 16 }); rs.on("data", () => hit()); rs.on("error", () => {});`, + }, + { + name: "fs.watch", + phases: ALL, + worker: ` + const fs = require("node:fs"), path = require("node:path"); + const dir = fs.mkdtempSync(path.join(require("node:os").tmpdir(), "wtf-")); + fs.watch(dir, () => hit()); + parentPort.postMessage({ dir }); + armed(); + `, + host: phase => { + let dir = ""; + let n = 0; + let pump: ReturnType | undefined; + const touch = () => require("node:fs").writeFileSync(require("node:path").join(dir, "f" + n++), "x"); + return { + // A platform watcher may coalesce or drop the event for a file created + // right after it was armed; keep producing changes until the callback ran. + onDir: (d: string) => { + dir = d; + if (phase !== "armed") { + touch(); + pump = setInterval(touch, 5); + } + }, + onSignal: () => { + clearInterval(pump); + if (phase === "after") touch(); + }, + close: () => { + clearInterval(pump); + try { + require("node:fs").rmSync(dir, { recursive: true, force: true }); + } catch {} + }, + } as any; + }, + }, + { + name: "Bun.file().text()", + phases: ["inside", "after"], + worker: `Bun.file(process.execPath).slice(0, 1 << 20).text().then(() => hit(() => Bun.file(process.execPath).slice(0, 4096).text()));`, + }, + { + name: "Bun.file().stream() pull", + phases: ["inside", "after"], + worker: `(async () => { const r = Bun.file(process.execPath).stream().getReader(); await r.read(); hit(() => r.read().catch(() => {})); await r.read().catch(() => {}); })();`, + }, + { + name: "Bun.write", + phases: ["inside", "after"], + worker: ` + const p = require("node:path").join(require("node:os").tmpdir(), "wtf-" + Math.random().toString(36).slice(2)); + Bun.write(p, Buffer.alloc(1 << 20)).then(() => hit(() => Bun.write(p, "y"))).finally(() => require("node:fs").rm(p, () => {})); + `, + }, + ], + + pool: [ + { + name: "crypto.pbkdf2", + phases: ["inside", "after"], + worker: `const c = require("node:crypto"); c.pbkdf2("p", "s", 1000, 32, "sha256", () => hit(() => c.pbkdf2("p", "s", 5000, 32, "sha256", () => {})));`, + }, + { + name: "crypto.scrypt", + phases: ["inside", "after"], + worker: `const c = require("node:crypto"); c.scrypt("p", "s", 32, () => hit(() => c.scrypt("p", "s", 32, () => {})));`, + }, + { + name: "crypto.randomFill", + phases: ["inside", "after"], + worker: `const c = require("node:crypto"); c.randomFill(Buffer.alloc(1 << 16), () => hit(() => c.randomFill(Buffer.alloc(1 << 16), () => {})));`, + }, + { + name: "crypto.generateKeyPair", + phases: ["inside", "after"], + worker: `const c = require("node:crypto"); c.generateKeyPair("ec", { namedCurve: "P-256" }, () => hit(() => c.generateKeyPair("ec", { namedCurve: "P-256" }, () => {})));`, + }, + { + name: "crypto.subtle.digest", + phases: ["inside", "after"], + worker: `crypto.subtle.digest("SHA-256", Buffer.alloc(1 << 16)).then(() => hit(() => crypto.subtle.digest("SHA-256", Buffer.alloc(1 << 20))));`, + }, + { + name: "zlib.gzip callback", + phases: ["inside", "after"], + worker: `const z = require("node:zlib"); z.gzip(Buffer.alloc(1 << 16), () => hit(() => z.gzip(Buffer.alloc(1 << 20), () => {})));`, + }, + { + name: "zlib stream data", + phases: ["inside", "after"], + worker: `const z = require("node:zlib"); const g = z.createGzip(); g.on("data", () => hit(() => g.write(Buffer.alloc(1 << 16)))); g.write(Buffer.alloc(1 << 16)); g.flush();`, + }, + { + name: "CompressionStream", + phases: ["inside", "after"], + worker: ` + (async () => { const cs = new CompressionStream("gzip"); const w = cs.writable.getWriter(); const r = cs.readable.getReader(); + w.write(new Uint8Array(1 << 17)); await r.read(); hit(() => { w.write(new Uint8Array(1 << 17)); r.read().catch(() => {}); }); })(); + `, + }, + { + name: "Bun.password.hash", + phases: ["inside", "after"], + worker: `Bun.password.hash("x", { algorithm: "bcrypt", cost: 4 }).then(() => hit(() => Bun.password.hash("y", { algorithm: "bcrypt", cost: 4 })));`, + }, + { + name: "Bun.Glob scan", + phases: ["inside", "after"], + worker: `(async () => { const it = new Bun.Glob("*").scan({ cwd: require("node:os").tmpdir() })[Symbol.asyncIterator](); await it.next(); hit(() => it.next().catch(() => {})); })();`, + }, + ], + + subprocess: [ + { + name: "child_process exit", + phases: ["inside", "after"], + worker: `const cp = require("node:child_process"); cp.execFile(process.execPath, ["-e", "0"], () => hit(() => cp.execFile(process.execPath, ["-e", "0"], () => {})));`, + }, + { + name: "child stdout data", + phases: ["inside", "after"], + worker: `const cp = require("node:child_process"); const c = cp.spawn(process.execPath, ["-e", "process.stdout.write('x'.repeat(1<<16))"]); c.stdout.on("data", () => hit()); c.on("error", () => {});`, + }, + { + name: "Bun.spawn exited + stdout", + phases: ["inside", "after"], + worker: `(async () => { const p = Bun.spawn([process.execPath, "-e", "console.log('x')"], { stdout: "pipe" }); await p.exited; hit(() => Bun.spawn([process.execPath, "-e", "0"])); await p.stdout.text(); })();`, + }, + { + name: "Bun.$", + phases: ["inside", "after"], + worker: + "(async () => { await Bun.$`echo hi`.quiet(); hit(() => Bun.$`echo again`.quiet().catch(() => {})); })();", + }, + { + name: "child running at stop", + phases: ["armed"], + worker: `const c = require("node:child_process").spawn(process.execPath, ["-e", "setTimeout(()=>{}, 30000)"]); c.on("spawn", () => armed()); c.on("error", () => {});`, + }, + ], + + dns: [ + { + name: "dns.lookup", + phases: ["inside", "after"], + worker: `const dns = require("node:dns"); dns.lookup("localhost", () => hit(() => dns.lookup("localhost", { all: true }, () => {})));`, + }, + { + name: "dns.promises.lookup", + phases: ["inside", "after"], + worker: `const dns = require("node:dns"); dns.promises.lookup("localhost").then(() => hit(() => dns.promises.lookup("localhost", { family: 6 }).catch(() => {})));`, + }, + { + name: "Bun.dns.lookup", + phases: ["inside", "after"], + worker: `Bun.dns.lookup("localhost").then(() => hit(() => Bun.dns.lookup("localhost", { family: 4 })));`, + }, + { + name: "lookup in flight at stop", + phases: ["armed"], + worker: `parentPort.on("message", () => {}); require("node:dns").lookup("localhost", () => {}); armed();`, + }, + ], + + loader: [ + { + name: "dynamic import settle", + phases: ["inside", "after"], + worker: `import("node:zlib").then(() => hit(() => import("node:tls")));`, + }, + { + name: "require inside a callback", + phases: ["inside", "after"], + worker: `setImmediate(() => { hit(); require("node:https"); require("node:vm"); });`, + }, + { + name: "vm.runInContext with timeout", + phases: ["inside", "after"], + worker: `const vm = require("node:vm"); setImmediate(() => { hit(() => vm.runInNewContext("1", {}, { timeout: 1000 })); vm.runInNewContext("for (let i=0;i<1e5;i++);", {}, { timeout: 1000 }); });`, + }, + { + name: "FinalizationRegistry callback", + phases: ["inside", "after"], + worker: ` + parentPort.on("message", () => {}); + const fr = new FinalizationRegistry(() => hit(() => { fr.register({}, 2); Bun.gc(true); })); + (() => { fr.register({}, 1); })(); + Bun.gc(true); setImmediate(() => Bun.gc(true)); + `, + }, + { + name: "Atomics.waitAsync settle", + phases: ["inside", "after"], + worker: ` + const ia = new Int32Array(new SharedArrayBuffer(4)); + Atomics.waitAsync(ia, 0, 0, 10).value.then(() => hit(() => Atomics.waitAsync(ia, 0, 0, 10))); + parentPort.on("message", () => {}); + `, + }, + { + name: "WebAssembly.instantiate settle", + phases: ["inside", "after"], + worker: `const bytes = new Uint8Array([0,97,115,109,1,0,0,0]); WebAssembly.instantiate(bytes).then(() => hit(() => WebAssembly.instantiate(bytes)));`, + }, + { + name: "EventTarget dispatch", + phases: ["inside", "after"], + worker: `const et = new EventTarget(); et.addEventListener("x", () => hit(() => et.dispatchEvent(new Event("x")))); setImmediate(() => et.dispatchEvent(new Event("x")));`, + }, + { + name: "process.on('exit') handlers", + phases: ["armed"], + worker: `parentPort.on("message", () => {}); process.on("exit", () => {}); armed();`, + }, + ], + + counted: [ + { + name: "Bun.build plugin onLoad pending", + phases: ["armed"], + worker: ` + parentPort.on("message", () => {}); + Bun.build({ entrypoints: ["virtual:entry"], plugins: [{ name: "p", setup(b) { + b.onResolve({ filter: /^virtual:/ }, a => ({ path: a.path, namespace: "v" })); + b.onLoad({ filter: /.*/, namespace: "v" }, () => new Promise(() => { armed(); })); + } }] }).catch(() => {}); + `, + }, + { + name: "zlib stream write in flight", + phases: ["armed"], + worker: `parentPort.on("message", () => {}); const g = require("node:zlib").createGzip(); g.on("data", () => {}); g.write(Buffer.alloc(1 << 22)); armed();`, + }, + { + name: "fetch body streaming at stop", + phases: ["armed"], + worker: ` + parentPort.on("message", () => {}); + fetch("http://127.0.0.1:" + params.port + "/stream").then(r => r.body.getReader().read()).then(() => armed()).catch(() => {}); + `, + host: phase => httpPeer(phase), + }, + { + name: "resources open at stop", + phases: ["armed"], + worker: ` + const { Database } = require("bun:sqlite"); const db = new Database(":memory:"); db.run("create table t(a)"); + Bun.serve({ port: 0, development: false, fetch: () => new Response("x") }); + require("node:dgram").createSocket("udp4").bind(0); + require("node:fs").watch(require("node:os").tmpdir(), () => {}); + Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + setInterval(() => {}, 1000); + armed(); + `, + }, + ], +}; + +// An HTTP peer in the parent: "/armed" never answers; anything else streams a +// chunk immediately and another on signal; nothing ever completes the body. +async function httpPeer(phase: Phase): Promise { + const pending = new Set(); + const enc = new TextEncoder(); + const srv = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + idleTimeout: 0, + development: false, + fetch(req) { + if (new URL(req.url).pathname === "/armed") return new Promise(() => {}); + return new Response( + new ReadableStream({ + start(c) { + c.enqueue(enc.encode("x".repeat(1024))); + pending.add(c); + }, + cancel() {}, + }), + ); + }, + }); + return { + params: { port: srv.port }, + onSignal: () => { + if (phase === "after") + for (const c of pending) { + try { + c.enqueue(enc.encode("y")); + } catch {} + } + }, + close: () => { + for (const c of pending) { + try { + c.close(); + } catch {} + } + srv.stop(true); + }, + }; +} + +// A one-connection TCP peer in the parent. +async function tcpPeer( + phase: Phase, + o: { writeOnOpen?: boolean; endOnOpen?: boolean; writeOnSignal?: boolean }, +): Promise { + let sock: net.Socket | undefined; + const server = net.createServer(c => { + sock = c; + c.on("error", () => {}); + if (o.writeOnOpen) c.write("x"); + if (o.endOnOpen) c.end("x"); + }); + await new Promise(r => server.listen(0, "127.0.0.1", () => r())); + return { + params: { port: (server.address() as net.AddressInfo).port }, + onSignal: () => { + if (o.writeOnSignal) sock?.write("y"); + }, + close: () => { + sock?.destroy(); + server.close(); + }, + }; +} + +const DEADLINE_MS = 20_000; + +async function runCase(entry: Entry, phase: Phase): Promise { + const ctl = new Int32Array(new SharedArrayBuffer(16)); + let w!: Worker; + const h: any = (await entry.host?.(phase, () => w)) ?? {}; + const src = PRELUDE + entry.worker; + w = new Worker(src, { eval: true, workerData: { ctl, phase, params: h.params }, transferList: h.transfer ?? [] }); + const exited = new Promise(res => w.once("exit", res)); + let error: unknown = null; + w.on("error", e => (error = e)); + let received = 0; + w.on("message", m => { + received++; + if (typeof m === "number" && received === 1) h.onPort?.(m); + else if (m && typeof m === "object" && "dir" in m) h.onDir?.(m.dir); + else if (m === "ready") w.postMessage(1); + }); + + const deadline = new Promise<"deadline">(res => setTimeout(() => res("deadline"), DEADLINE_MS).unref()); + if (h.natural) { + const code = await Promise.race([exited, deadline]); + h.close?.(); + if (code === "deadline") { + void w.terminate(); + return "no exit"; + } + if (received !== h.natural) return `got ${received}/${h.natural} messages before exit`; + return null; + } + // Wait for the worker to reach the point. + const { async, value } = Atomics.waitAsync(ctl, 0, 0, DEADLINE_MS); + const reached = async ? await Promise.race([value, exited.then(() => "exited-early")]) : value; + if (reached !== "ok" && reached !== "not-equal") { + h.close?.(); + void w.terminate(); + return `never reached the ${phase} point (${reached}${error ? "; " + (error as Error).message : ""})`; + } + await h.onSignal?.(); + const t = w.terminate(); + Atomics.store(ctl, 1, 1); + Atomics.notify(ctl, 1); // release an "inside" callback + const code = await Promise.race([exited, deadline]); + h.close?.(); + if (code === "deadline") return "no exit after terminate()"; + await t; + return null; +} + +const family = process.argv[2]; +const entries = FAMILIES[family]; +if (!entries) { + console.error("unknown family " + family + "; have " + Object.keys(FAMILIES).join(",")); + process.exit(2); +} +if (process.argv[3] === "--list") { + console.log(Object.keys(FAMILIES).join("\n")); + process.exit(0); +} + +const cases = entries.flatMap(e => e.phases.map(p => [e, p] as const)); +const failures: string[] = []; +// Bounded concurrency keeps wall time low without piling up live VMs. +const K = 6; +let next = 0; +await Promise.all( + Array.from({ length: K }, async () => { + while (next < cases.length) { + const [e, p] = cases[next++]; + const why = await runCase(e, p).catch(err => "host error: " + (err?.stack ?? err)); + if (why) failures.push(`${e.name} [${p}]: ${why}`); + } + }), +); +if (failures.length) { + console.log("FAIL\n" + failures.join("\n")); + process.exit(1); +} +console.log("ok " + cases.length); +process.exit(0); diff --git a/test/js/web/workers/worker-terminate-funnels.test.ts b/test/js/web/workers/worker-terminate-funnels.test.ts new file mode 100644 index 000000000000..d983af5c0613 --- /dev/null +++ b/test/js/web/workers/worker-terminate-funnels.test.ts @@ -0,0 +1,51 @@ +// A worker is terminated at three fixed points relative to each native→JS entry +// point (see worker-terminate-funnels-fixture.ts): while the callback is armed +// but cannot have fired, while JS is inside it, and with its follow-on +// completion in flight. Every case must end with 'exit' and nothing else. On +// debug/ASAN builds the runtime's own assertions turn "script entered after the +// stop was requested", use-after-free and leaked handles into failures here. +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isASAN, isDebug } from "harness"; +import { join } from "node:path"; + +const fixture = join(import.meta.dirname, "worker-terminate-funnels-fixture.ts"); + +const FAMILIES: Record = { + timers: 16, + messaging: 13, + net: 17, + http: 18, + fs: 15, + pool: 20, + subprocess: 9, + dns: 7, + loader: 15, + counted: 4, +}; + +describe.concurrent("terminate() at every native→JS entry point", () => { + for (const [family, cases] of Object.entries(FAMILIES)) { + test( + family, + async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), fixture, family], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // stderr carries the runtime's own diagnostics on a failure; show it first. + // On a failure stdout is "FAIL" plus one line per case (" []: "). + expect({ stdout: stdout.trim(), exitCode, stderr: exitCode === 0 ? "" : stderr }).toEqual({ + stdout: `ok ${cases}`, + exitCode: 0, + stderr: "", + }); + // ~150 worker lifecycles across ten host processes: a second or two in + // release; the debug/ASAN builds get headroom for running them all at once. + }, + isDebug || isASAN ? 30_000 : 10_000, + ); + } +}); diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index 9e20bd0d8b4d..49e7a197ed19 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { once } from "events"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; import path from "path"; import wt from "worker_threads"; @@ -335,6 +335,208 @@ describe("web worker", () => { expect(err.error).toBe(null); }); }); + + describe("terminate() races and lifecycle edges", () => { + // A vm timeout inside a worker is a transient termination of that VM; it + // must not leave the worker unable to run script (parent messages dropped). + test("parent messages still arrive after a node:vm timeout in the worker", async () => { + const src = `import vm from "node:vm"; + self.onmessage = e => postMessage("pong " + e.data); + try { vm.runInNewContext("for(;;){}", {}, { timeout: 20 }) } catch {} + postMessage("ready");`; + const w = new Worker(URL.createObjectURL(new Blob([src]))); + const got: string[] = []; + const done = Promise.withResolvers(); + w.onmessage = e => { + if (e.data === "ready") { + for (let i = 0; i < 3; i++) w.postMessage(i); + return; + } + got.push(e.data); + if (got.length === 3) done.resolve(); + }; + await done.promise; + expect(got).toEqual(["pong 0", "pong 1", "pong 2"]); + w.terminate(); + }); + + // As in browsers and Node: not an error, the message is dropped. + test("postMessage() to a terminated worker is a no-op", async () => { + const w = new Worker("data:text/javascript,postMessage('up')"); + await new Promise(r => (w.onmessage = r)); + w.terminate(); + await once(w, "close"); + expect(() => w.postMessage("late")).not.toThrow(); + }); + + // A data: URL is the module itself and never a path (no length limit). + test("a long data: URL worker", async () => { + const pad = "/*" + Buffer.alloc(4000, "x").toString() + "*/"; + const w = new Worker("data:text/javascript," + encodeURIComponent(pad + "postMessage('hi')")); + const [msg] = await once(w, "message"); + expect(msg.data).toBe("hi"); + w.terminate(); + }); + + // terminate() landing while the worker reports that its entry point does not + // resolve: the report is skipped, not turned into a panic. + test("terminate() while the entry point fails to resolve", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `let done = 0; + async function one(i) { + const w = new Worker("/nonexistent/path-" + i + ".js"); + const closed = new Promise(r => w.addEventListener("close", r)); + w.onerror = () => {}; + setTimeout(() => w.terminate(), i % 8); + await closed; + done++; + } + for (let r = 0; r < 12; r++) await Promise.all(Array.from({ length: 8 }, (_, i) => one(r * 8 + i))); + console.log("done", done);`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("done 96\n"); + expect(exitCode).toBe(0); + }); + + // A worker posting faster than the parent can deserialize must not pin the + // parent inside one drain: its timers and I/O still get their turn. + test("a message flood from a worker does not starve the parent's event loop", async () => { + const src = `const p = { s: Buffer.alloc(200, "x").toString(), a: [1, 2, 3], n: 0 }; + (function burst() { for (let i = 0; i < 2000; i++) { p.n++; postMessage(p) } setImmediate(burst) })()`; + const w = new Worker(URL.createObjectURL(new Blob([src]))); + let received = 0; + w.onmessage = () => received++; + // Three timer turns while the flood is running is the property; not the timing. + for (let i = 0; i < 3; i++) await new Promise(r => setTimeout(r, 10)); + expect(received).toBeGreaterThan(0); + w.terminate(); + await once(w, "close"); + }); + + // node:vm's timeout machinery shares the VM's termination bit with + // terminate(); a terminate() landing mid-script is not a vm timeout. + test("terminate() while a node:vm script with a timeout is running", async () => { + const src = `import vm from "node:vm"; postMessage("busy"); + for (;;) { try { vm.runInNewContext("for(let i=0;i<1e7;i++){}", {}, { timeout: 1000 }) } catch {} await new Promise(r => setImmediate(r)) }`; + const url = URL.createObjectURL(new Blob([src])); + for (let r = 0; r < 6; r++) { + const w = new Worker(url); + await new Promise(res => (w.onmessage = res)); + w.terminate(); + await once(w, "close"); + } + }); + + // terminate() mid `import "node:*"`: the native module's export walk stops + // at the termination instead of clearing it and reading on. + test("terminate() while importing every builtin module", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `import { builtinModules } from "node:module"; + const L = builtinModules.filter(m => !m.startsWith("_") && !/^(bun|detect-libc|undici|ws)/.test(m)); + const src = "for (const b of " + JSON.stringify(L) + ") { try { await import('node:' + b) } catch {} } postMessage('done')"; + const url = URL.createObjectURL(new Blob([src])); + for (let r = 0; r < 6; r++) await Promise.all(Array.from({ length: 4 }, (_, i) => new Promise(res => { + const w = new Worker(url); w.addEventListener("close", res); w.onmessage = () => w.terminate(); + setTimeout(() => w.terminate(), (r * 4 + i) * 15) }))); + console.log("PASS");`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("PASS\n"); + expect(exitCode).toBe(0); + }); + + // A preload's un-awaited import() finishing while the entry is still + // fetching is not "the entry started evaluating": message delivery opens + // only once the entry's own graph runs (and installs its handler). + test("preload with an un-awaited import() does not open message delivery before the entry runs", async () => { + using dir = tempDir("worker-preload-dynamic-import", { + "side.js": `globalThis.sideRan = true;`, + "preload.js": `import("./side.js");`, + // big enough that the entry graph is still transpiling when side.js evaluates + "big.js": Array.from( + { length: 4000 }, + (_, i) => `export function f${i}(x) { return x * ${i} + ${i % 7}; }`, + ).join("\n"), + "worker.js": `import "./big.js"; + const got = []; + self.onmessage = e => { got.push(e.data); if (e.data === "last") postMessage(got); };`, + }); + for (let i = 0; i < 3; i++) { + const w = new Worker(path.join(String(dir), "worker.js"), { preload: [path.join(String(dir), "preload.js")] }); + w.postMessage("first"); + w.postMessage("second"); + w.postMessage("last"); + const [ev] = await once(w, "message"); + expect(ev.data).toEqual(["first", "second", "last"]); + w.terminate(); + } + }); + + // Everything a worker posted before it exited arrives before 'close'. + test("messages posted right before a natural exit are all delivered before close", async () => { + const K = 5000; + const src = `const p = Buffer.alloc(256, "x").toString(); for (let i = 0; i < ${K}; i++) postMessage({ i, p })`; + const url = URL.createObjectURL(new Blob([src])); + for (let r = 0; r < 3; r++) { + let got = 0; + const w = new Worker(url); + w.onmessage = () => got++; + await once(w, "close"); + expect(got).toBe(K); + } + }); + + // process.exit() from inside nested node:vm contexts in a worker: the + // termination unwinds through both frames like any exception. + test("process.exit() from a nested node:vm context inside a worker", async () => { + const src = `const vm = require("node:vm"); postMessage("in"); + vm.runInNewContext('run("exit(0)")', { run: s => vm.runInNewContext(s, { exit: process.exit.bind(process) }) })`; + const w = new Worker(URL.createObjectURL(new Blob([src]))); + const [ev] = await once(w, "close"); + expect(ev.code).toBe(0); + }); + + // fs completions racing terminate(): whatever completes on the worker + // after the request must release, not build script values under it. + test("terminate() while fs.readFile completions keep arriving", async () => { + using dir = tempDir("worker-readfile-churn", { "f.bin": Buffer.alloc(65536, 7) }); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const src = \`import { readFile } from "node:fs"; + let n = 0; (function pump(){ while (n < 16) { n++; readFile(\${JSON.stringify(process.argv[1])}, () => { n--; setImmediate(pump) }) } })(); + postMessage("busy")\`; + const url = URL.createObjectURL(new Blob([src])); + for (let r = 0; r < 12; r++) await Promise.all(Array.from({ length: 4 }, (_, i) => new Promise(res => { + const w = new Worker(url); w.addEventListener("close", res); w.onmessage = () => setTimeout(() => w.terminate(), (r + i) % 10) }))); + console.log("PASS");`, + path.join(String(dir), "f.bin"), + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("PASS\n"); + expect(exitCode).toBe(0); + }); + }); }); // TODO: move to node:worker_threads tests directory @@ -355,16 +557,8 @@ describe("worker_threads", () => { }); test("worker terminate while setting up thread", async () => { - // this test is inherently somewhat flaky: if we call terminate() before the worker starts - // running any JavaScript the code will be 0 like we expect, but if we terminate while it is - // running code the exit code is 1 instead (this happens in Node.js too). this means we can - // randomly see an exit code of 1 if the main thread happens to run slower than usual and allows - // the worker to run some code. - // - // to prevent it from polluting the flaky test list, we try 10 times and expect: - // - at least 1 time the exit code was 0 - // - the exit code is never something other than 0 or 1 - const codes: number[] = []; + // As in Node: a worker stopped by terminate() reports 1 once its thread's environment + // exists, 0 only if it was stopped before that. Which one depends on timing. for (let i = 0; i < 10; i++) { const worker = new wt.Worker(new URL("worker-fixture-hang.js", import.meta.url), { smol: true, @@ -372,9 +566,7 @@ describe("worker_threads", () => { worker.on("error", expect.unreachable); const code = await worker.terminate(); expect(code === 0 || code === 1, `unexpected exit code ${code}`).toBeTrue(); - codes.push(code); } - expect(codes.includes(0)).toBeTrue(); }); test("worker with process.exit (delay) and terminate", async () => { diff --git a/test/napi/napi-app/async_tests.cpp b/test/napi/napi-app/async_tests.cpp index 6238268eb9cf..98db90fe5355 100644 --- a/test/napi/napi-app/async_tests.cpp +++ b/test/napi/napi-app/async_tests.cpp @@ -513,7 +513,51 @@ create_threadsafe_function_after_teardown(const Napi::CallbackInfo &info) { return info.Env().Undefined(); } +// A finalizer that runs while the env drains its finalizers at teardown and +// registers another one: it creates an external buffer with a finalize_cb. +// That late finalizer must run in the same teardown. Counted process-wide so +// the parent thread can read it after the worker is gone. +static std::atomic late_finalizer_runs{0}; + +static void late_buffer_finalizer(napi_env env, void *data, void *hint) { + late_finalizer_runs.fetch_add(1); + free(data); +} + +static void finalizer_that_creates_external_buffer(napi_env env, void *data, + void *hint) { + free(data); + void *bytes = malloc(16); + napi_value buffer; + // Script is refused during teardown, but N-API object creation is not: this + // registers `late_buffer_finalizer` with the env from inside its cleanup. + napi_status status = napi_create_external_buffer( + env, 16, bytes, late_buffer_finalizer, nullptr, &buffer); + if (status != napi_ok) { + free(bytes); + } +} + +// napi_wrap's finalizer is env-bound: for an object still alive when the +// worker exits it runs from the env's cleanup (heap alive), not from GC. +napi_value +create_object_whose_finalizer_creates_external_buffer(const Napi::CallbackInfo &info) { + napi_env env = info.Env(); + napi_value object; + NODE_API_CALL(env, napi_create_object(env, &object)); + NODE_API_CALL(env, napi_wrap(env, object, malloc(1), + finalizer_that_creates_external_buffer, nullptr, + nullptr)); + return object; +} + +napi_value late_finalizer_run_count(const Napi::CallbackInfo &info) { + return Napi::Number::New(info.Env(), late_finalizer_runs.load()); +} + void register_async_tests(Napi::Env env, Napi::Object exports) { + REGISTER_FUNCTION(env, exports, create_object_whose_finalizer_creates_external_buffer); + REGISTER_FUNCTION(env, exports, late_finalizer_run_count); REGISTER_FUNCTION(env, exports, create_promise); REGISTER_FUNCTION(env, exports, create_promise_with_napi_cpp); REGISTER_FUNCTION(env, exports, create_promise_with_threadsafe_function); diff --git a/test/napi/napi-app/module.js b/test/napi/napi-app/module.js index 702ed54fc87e..0ba793403d9a 100644 --- a/test/napi/napi-app/module.js +++ b/test/napi/napi-app/module.js @@ -1392,6 +1392,13 @@ nativeTests.test_threadsafe_function_orphaned_by_worker = async () => { console.log(nativeTests.use_orphaned_threadsafe_functions()); }; +// A finalizer that runs during a worker's env cleanup and registers another +// finalizer (an external buffer's): the late one runs in that same cleanup. +nativeTests.test_finalizer_registered_during_env_cleanup = async () => { + console.log("worker exited with", await runOrphanWorker({ lateFinalizer: true })); + console.log("late=" + nativeTests.late_finalizer_run_count()); +}; + // Bun-only: an orphaned threadsafe function is freed by whichever thread drops // its last reference, including a call that reports napi_closing. Every // iteration must end with as many live threadsafe functions as it started with. diff --git a/test/napi/napi-app/tsfn-orphan-worker.js b/test/napi/napi-app/tsfn-orphan-worker.js index b4d00ebb111a..1d01e11f6d4c 100644 --- a/test/napi/napi-app/tsfn-orphan-worker.js +++ b/test/napi/napi-app/tsfn-orphan-worker.js @@ -4,7 +4,11 @@ const nativeTests = require("./build/Debug/napitests.node"); // Create unref'd threadsafe functions owned by the addon, then let this worker // exit: the addon keeps its thread_count references across the worker's // teardown and uses them afterwards from one of its own threads. -if (workerData?.leak) { +if (workerData?.lateFinalizer) { + // Kept alive to worker exit, so its finalizer runs during env cleanup (not + // GC) and registers another finalizer from there. + globalThis.keep = nativeTests.create_object_whose_finalizer_creates_external_buffer(); +} else if (workerData?.leak) { nativeTests.create_leaked_threadsafe_functions(workerData.leak, () => console.log("worker: leaked tsfn must never be called"), ); diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 688765c651c1..eed5a385af46 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -610,6 +610,24 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { expect(result).toContain("worker exited with 0\nfinalized=2 call=16 release=0"); }); + // A finalizer running while a worker's env drains its finalizers can + // register another (here: an external buffer with a finalize_cb). Bun runs + // that one in the same cleanup rather than leaving it behind the walk. Bun- + // only rather than same-output: node hands an external buffer's finalizer to + // the BackingStore deleter, not to the env's tracked references, so a buffer + // created during teardown dies with the isolate and node prints late=0. + it("runs a finalizer that another finalizer registered during env cleanup", async () => { + await using proc = spawn({ + cmd: [bunExe(), join(__dirname, "napi-app/main.js"), "test_finalizer_registered_during_env_cleanup", "[]"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toContain("worker exited with 0\nlate=1"); + expect(exitCode).toBe(0); + }); + // A call that reports napi_closing consumes the calling thread's reference // (node's ThreadSafeFunction::Push), so on an orphaned threadsafe function // it can drop the last one -- and then it must free it, or every worker that diff --git a/test/napi/node-napi-tests/test/node-api/test_worker_buffer_callback/do.test.ts b/test/napi/node-napi-tests/test/node-api/test_worker_buffer_callback/do.test.ts index a7bf87409005..ec9de2b7e8ee 100644 --- a/test/napi/node-napi-tests/test/node-api/test_worker_buffer_callback/do.test.ts +++ b/test/napi/node-napi-tests/test/node-api/test_worker_buffer_callback/do.test.ts @@ -6,8 +6,8 @@ test("build", async () => { }); for (const file of Array.from(new Bun.Glob("*.js").scanSync(import.meta.dir))) { - // AssertionError: Missing expected exception (DataCloneError). - test.todoIf(["test.js", "test-free-called.js"].includes(file))(file, () => { + // test.js: AssertionError: Missing expected exception (DataCloneError). + test.todoIf(["test.js"].includes(file))(file, () => { run(dirname(import.meta.dir), basename(import.meta.dir) + sep + file); }); } diff --git a/test/napi/node-napi-tests/test/node-api/test_worker_terminate/do.test.ts b/test/napi/node-napi-tests/test/node-api/test_worker_terminate/do.test.ts index 7a6f943d8f52..727e5dc80ad6 100644 --- a/test/napi/node-napi-tests/test/node-api/test_worker_terminate/do.test.ts +++ b/test/napi/node-napi-tests/test/node-api/test_worker_terminate/do.test.ts @@ -6,8 +6,7 @@ test("build", async () => { }); for (const file of Array.from(new Bun.Glob("*.js").scanSync(import.meta.dir))) { - // Assertion failed: (status == napi_pending_exception), function Test, file test_worker_terminate.c, line 21. - test.todoIf(["test.js"].includes(file))(file, () => { + test(file, () => { run(dirname(import.meta.dir), basename(import.meta.dir) + sep + file); }); } diff --git a/test/no-validate-exceptions.txt b/test/no-validate-exceptions.txt index 51fad268e06f..9749d3b5846d 100644 --- a/test/no-validate-exceptions.txt +++ b/test/no-validate-exceptions.txt @@ -72,6 +72,8 @@ test/napi/node-napi-tests/test/node-api/test_general/do.test.ts test/napi/node-napi-tests/test/node-api/test_make_callback/do.test.ts test/napi/node-napi-tests/test/node-api/test_threadsafe_function/do.test.ts test/napi/node-napi-tests/test/node-api/test_worker_terminate_finalization/do.test.ts +test/napi/node-napi-tests/test/node-api/test_worker_terminate/do.test.ts +test/napi/node-napi-tests/test/node-api/test_worker_buffer_callback/do.test.ts test/napi/node-napi-tests/test/node-api/test_reference_by_node_api_version/do.test.ts test/napi/node-napi-tests/test/node-api/test_env_teardown_gc/do.test.ts test/napi/node-napi-tests/test/node-api/test_general/do.test.ts diff --git a/test/no-validate-leaksan.txt b/test/no-validate-leaksan.txt index 6d9f6647cf64..e4f53b8bfa48 100644 --- a/test/no-validate-leaksan.txt +++ b/test/no-validate-leaksan.txt @@ -3,69 +3,9 @@ test/cli/install/bun-security-scanner-matrix-with-node-modules.test.ts test/cli/install/bun-security-scanner-matrix-without-node-modules.test.ts -test/js/node/test/parallel/test-worker-abort-on-uncaught-exception.js -test/js/node/test/parallel/test-worker-arraybuffer-zerofill.js -test/js/node/test/parallel/test-worker-cjs-workerdata.js -test/js/node/test/parallel/test-worker-cleanexit-with-js.js -test/js/node/test/parallel/test-worker-cleanexit-with-moduleload.js -test/js/node/test/parallel/test-worker-console-listeners.js -test/js/node/test/parallel/test-worker-dns-terminate-during-query.js -test/js/node/test/parallel/test-worker-environmentdata.js -test/js/node/test/parallel/test-worker-esm-exit.js -test/js/node/test/parallel/test-worker-esm-missing-main.js -test/js/node/test/parallel/test-worker-esmodule.js -test/js/node/test/parallel/test-worker-event.js -test/js/node/test/parallel/test-worker-exit-event-error.js -test/js/node/test/parallel/test-worker-exit-from-uncaught-exception.js -test/js/node/test/parallel/test-worker-exit-heapsnapshot.js -test/js/node/test/parallel/test-worker-fs-stat-watcher.js -test/js/node/test/parallel/test-worker-heap-snapshot.js -test/js/node/test/parallel/test-worker-http2-generic-streams-terminate.js -test/js/node/test/parallel/test-worker-invalid-workerdata.js -test/js/node/test/parallel/test-worker-load-file-with-extension-other-than-js.js -test/js/node/test/parallel/test-worker-memory.js -test/js/node/test/parallel/test-worker-message-channel-sharedarraybuffer.js -test/js/node/test/parallel/test-worker-message-event.js -test/js/node/test/parallel/test-worker-message-port-constructor.js -test/js/node/test/parallel/test-worker-message-port-infinite-message-loop.js -test/js/node/test/parallel/test-worker-message-port-receive-message.js -test/js/node/test/parallel/test-worker-message-port-terminate-transfer-list.js -test/js/node/test/parallel/test-worker-message-port-transfer-duplicate.js -test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js -test/js/node/test/parallel/test-worker-message-port-wasm-module.js -test/js/node/test/parallel/test-worker-message-port-wasm-threads.js -test/js/node/test/parallel/test-worker-mjs-workerdata.js -test/js/node/test/parallel/test-worker-nested-on-process-exit.js -test/js/node/test/parallel/test-worker-nested-uncaught.js -test/js/node/test/parallel/test-worker-no-sab.js -test/js/node/test/parallel/test-worker-non-fatal-uncaught-exception.js -test/js/node/test/parallel/test-worker-on-process-exit.js -test/js/node/test/parallel/test-worker-onmessage-not-a-function.js -test/js/node/test/parallel/test-worker-onmessage.js -test/js/node/test/parallel/test-worker-parent-port-ref.js -test/js/node/test/parallel/test-worker-process-argv.js -test/js/node/test/parallel/test-worker-ref-onexit.js -test/js/node/test/parallel/test-worker-ref.js -test/js/node/test/parallel/test-worker-relative-path-double-dot.js -test/js/node/test/parallel/test-worker-relative-path.js -test/js/node/test/parallel/test-worker-safe-getters.js -test/js/node/test/parallel/test-worker-sharedarraybuffer-from-worker-thread.js -test/js/node/test/parallel/test-worker-terminate-http2-respond-with-file.js -test/js/node/test/parallel/test-worker-terminate-nested.js -test/js/node/test/parallel/test-worker-terminate-null-handler.js -test/js/node/test/parallel/test-worker-terminate-timers.js -test/js/node/test/parallel/test-worker-type-check.js -test/js/node/test/parallel/test-worker-uncaught-exception-async.js -test/js/node/test/parallel/test-worker-unref-from-message-during-exit.js -test/js/node/test/parallel/test-worker-workerdata-sharedarraybuffer.js -test/js/node/test/parallel/test-worker.js -test/js/node/test/parallel/test-worker.mjs -test/js/node/worker_threads/worker_destruction.test.ts # Two tests hit their 5s timeout under BUN_DESTRUCT_VM_ON_EXIT; passes without # LSAN and no sanitizer report, just slow VM teardown. test/js/node/watch/fs.watch.test.ts -test/js/web/broadcastchannel/broadcast-channel.test.ts -test/js/web/broadcastchannel/broadcast-channel-worker-gc.test.ts # error exit root cause unclear @@ -154,7 +94,6 @@ test/cli/test/bun-test.test.ts test/cli/install/bun-install-security-provider.test.ts test/js/node/test/parallel/test-tls-fast-writing.js test/js/bun/sqlite/sqlite.test.js -test/js/workerd/html-rewriter.test.js test/regression/issue/12250.test.ts test/js/bun/test/parallel/test-http-10177-response.write-with-non-ascii-latin1-should-not-cause-duplicated-character-or-segfault.ts test/cli/install/minimum-release-age.test.ts @@ -170,18 +109,12 @@ test/bake/dev/stress.test.ts test/bake/dev/vfile.test.ts test/js/bun/http/serve.test.ts test/js/bun/resolve/import-meta.test.js -test/js/node/worker_threads/worker_threads.test.ts test/js/third_party/body-parser/express-bun-build-compile.test.ts # ASSERTION FAILED: m_normalWorld->hasOneRef() -test/js/node/test/parallel/test-unhandled-exception-with-worker-inuse.js test/js/node/test/parallel/test-process-beforeexit-throw-exit.js -test/js/node/test/parallel/test-async-hooks-worker-asyncfn-terminate-1.js test/js/node/test/parallel/test-crypto-prime.js -test/js/node/test/parallel/test-async-hooks-worker-asyncfn-terminate-4.js -test/js/node/test/parallel/test-async-hooks-worker-asyncfn-terminate-2.js -test/js/node/test/parallel/test-async-hooks-worker-asyncfn-terminate-3.js test/js/third_party/@fastify/websocket/fastity-test-websocket.test.js test/js/third_party/esbuild/esbuild-child_process.test.ts test/js/third_party/pino/pino.test.js @@ -189,8 +122,6 @@ test/js/third_party/socket.io/socket.io-close.test.ts test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts test/js/web/websocket/websocket-permessage-deflate-simple.test.ts test/js/web/websocket/websocket-upgrade.test.ts -test/js/web/workers/message-channel.test.ts -test/js/web/workers/worker_blob.test.ts test/regression/issue/012040.test.ts test/js/web/websocket/websocket-blob.test.ts test/regression/issue/14338.test.ts @@ -393,7 +324,6 @@ test/js/bun/util/reportError.test.ts test/js/node/fs/abort-signal-leak-read-write-file.test.ts test/js/node/process/process.test.js test/js/web/websocket/websocket.test.js -test/js/web/workers/worker.test.ts test/regression/issue/11664.test.ts # ASSERTION FAILED: m_cellState == CellState::DefinitelyWhite @@ -466,7 +396,3 @@ test/bundler/native-plugin.test.ts # Slow test/js/bun/typescript/type-export.test.ts - -# Worker termination exception in PropertyCallback -test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js -test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts \ No newline at end of file diff --git a/test/regression/issue/22978-createargv-double-free.test.ts b/test/regression/issue/22978-createargv-double-free.test.ts index 579874c154ea..86f948a666b4 100644 --- a/test/regression/issue/22978-createargv-double-free.test.ts +++ b/test/regression/issue/22978-createargv-double-free.test.ts @@ -43,7 +43,7 @@ test("process.argv with many arguments doesn't double-free", async () => { expect(result.hasScript).toBe(true); }); -test.todo("process.argv with many arguments in worker", async () => { +test("process.argv with many arguments in worker", async () => { // Test the worker code path as well const manyArgs = Array.from({ length: 129 }, (_, i) => `worker-arg${i}`);