diff --git a/src/event_loop/EventLoopTimer.rs b/src/event_loop/EventLoopTimer.rs index e69dfb58dc63..a1289bdda69f 100644 --- a/src/event_loop/EventLoopTimer.rs +++ b/src/event_loop/EventLoopTimer.rs @@ -224,6 +224,20 @@ impl Tag { Tag::TimeoutObject | Tag::AbortSignalTimeout | Tag::CronJob ) } + + /// Runtime-internal timers that fire forever or on the runtime's own behalf, so they never settle a program's promise. + pub fn is_housekeeping(self) -> bool { + matches!( + self, + Tag::WTFTimer + | Tag::DevServerSweepSourceMaps + | Tag::DevServerMemoryVisualizerTick + | Tag::DateHeaderTimer + | Tag::BunTest + | Tag::EventLoopDelayMonitor + | Tag::GcRepeating + ) + } } /// Stamp out one `unsafe fn $method(*const EventLoopTimer) -> *mut Self` per diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 3a0e43b786b6..3aa4bfde3161 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1197,20 +1197,23 @@ impl VirtualMachine { } } - pub fn is_event_loop_alive_excluding_immediates(&self) -> bool { + #[inline] + fn has_pending_loop_work_excluding_immediates(&self) -> bool { let el = self.event_loop_shared(); let active = self .platform_loop_opt() .map(|h| h.is_active()) .unwrap_or(false); - self.unhandled_error_counter == 0 - && ((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) + active + || self.active_tasks > 0 + || el.tasks.readable_length() > 0 + || !el.yield_tasks.is_empty() + || !el.concurrent_tasks.is_empty() + || el.has_pending_refs() + } + + pub fn is_event_loop_alive_excluding_immediates(&self) -> bool { + self.unhandled_error_counter == 0 && self.has_pending_loop_work_excluding_immediates() } pub fn is_event_loop_alive(&self) -> bool { @@ -1220,6 +1223,32 @@ impl VirtualMachine { || !el.next_immediate_tasks.is_empty() } + /// Whether anything, ref'd or not, could still settle a pending module promise (`unhandled_error_counter` is ignored: it persists across `bun test` files). + pub fn has_pending_loop_work(&self) -> bool { + let el = self.event_loop_shared(); + self.has_pending_loop_work_excluding_immediates() + || !el.immediate_tasks.is_empty() + || !el.next_immediate_tasks.is_empty() + || self.has_registered_io() + || runtime_hooks().is_some_and(|h| (h.has_program_timers)()) + } + + /// Sockets, pipes, child processes and watchers still registered with the platform loop, ref'd or not. + fn has_registered_io(&self) -> bool { + let Some(loop_) = self.platform_loop_opt() else { + return false; + }; + #[cfg(unix)] + { + // `hold_forever_poll` registers one poll of its own so watch-mode loops can park. + loop_.num_polls > i32::from(self.event_loop_shared().holds_forever_poll) + } + #[cfg(not(unix))] + { + loop_.has_active_io_handles() + } + } + pub fn wakeup(&mut self) { self.event_loop_mut().wakeup(); } @@ -2058,7 +2087,7 @@ pub struct RuntimeHooks { /// (error already logged into `vm.log`). pub generate_entry_point: fn(vm: &VirtualMachine, watch: bool, entry_path: &[u8]) -> bool, /// `loadPreloads()` — runs `--preload` scripts. Returns the first rejected - /// preload promise if any, else null. Errors propagate + /// or still-pending (unsettled top-level await) preload promise, else null. Errors propagate /// (resolver failures / `ModuleNotFound`). pub load_preloads: unsafe fn(vm: *mut VirtualMachine) -> crate::CrateResult<*mut JSInternalPromise>, @@ -2113,6 +2142,8 @@ pub struct RuntimeHooks { pub create_node_fs: unsafe fn(vm: *mut VirtualMachine) -> *mut c_void, /// `ObjectURLRegistry` lookup. Registry lives in `bun_runtime::webcore`. pub has_blob_url: fn(blob_id: &[u8]) -> bool, + /// `timer::All::has_program_timers` for this thread's VM; the heap lives in `bun_runtime`. + pub has_program_timers: fn() -> bool, /// `Response::get_blob_without_call_frame` / /// `Request::get_blob_without_call_frame`. If /// `value` downcasts to a `Response` or `Request` (both live in @@ -2643,6 +2674,39 @@ impl VirtualMachine { self.event_loop_mut().wait_for_promise(promise) } + /// Thin forwarder; body lives in [`crate::event_loop::EventLoop::wait_for_module_promise`]. + #[inline] + pub fn wait_for_module_promise( + &mut self, + promise: *mut JSInternalPromise, + ) -> Result<(), jsc::Stopped> { + self.event_loop_mut().wait_for_module_promise(promise) + } + + /// Node's "Detected unsettled top-level await" warning, one line per stalled module. + pub fn report_unsettled_top_level_await(&self) { + unsafe extern "C" { + fn Bun__findStalledTopLevelAwait(global: *mut JSGlobalObject) -> bun_core::String; + } + // SAFETY: `self.global` is the live per-thread global. + let stalled = unsafe { Bun__findStalledTopLevelAwait(self.global) }; + let stalled_utf8 = stalled.to_utf8(); + let at: &[u8] = if !stalled_utf8.slice().is_empty() { + stalled_utf8.slice() + } else { + &self.main + }; + for module in bun_core::strings::split(at, b"\n") { + bun_core::pretty_errorln!( + "Warning: Detected unsettled top-level await at {}", + bstr::BStr::new(module), + ); + } + bun_core::Output::flush(); + drop(stalled_utf8); + stalled.deref(); + } + /// `eventLoop().autoTick()` — dispatched through the runtime hook /// (needs `Timer::All` for the poll timeout). #[inline] @@ -2815,7 +2879,7 @@ impl VirtualMachine { } /// `loadEntryPoint(entry_path)` — `reload_entry_point` + spin until the - /// returned promise settles. + /// returned promise settles or nothing is left that could settle it (callers check the status). pub fn load_entry_point( &mut self, entry_path: &[u8], @@ -2846,7 +2910,7 @@ impl VirtualMachine { if crate::JSPromise::status_ptr(promise) == crate::js_promise::Status::Rejected { return Ok(promise); } - let _ = self.wait_for_promise(jsc::AnyPromise::Internal(promise)); + let _ = self.wait_for_module_promise(promise); } Ok(self.pending_internal_promise.unwrap_or(promise)) @@ -4895,7 +4959,7 @@ impl VirtualMachine { Ok(promise) } - /// Loads a test-file entry point and waits for the load promise to settle. + /// Loads a test-file entry point and waits for the load promise; it may still be pending on return. pub fn load_entry_point_for_test_runner( &mut self, entry_path: &[u8], @@ -4926,7 +4990,7 @@ impl VirtualMachine { if crate::JSPromise::status_ptr(promise) == crate::js_promise::Status::Rejected { return Ok(promise); } - let _ = self.wait_for_promise(jsc::AnyPromise::Internal(promise)); + let _ = self.wait_for_module_promise(promise); } // Pre-arm the waker so this settled-promise tick cannot park (#36450). diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 121c7a6d3c4f..87e146106b1e 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -757,6 +757,31 @@ static bool isModuleEvaluated(JSC::AbstractModuleRecord* record) return record->moduleEnvironmentMayBeNull() != nullptr; } +// '\n'-joined specifiers of the modules suspended on their own top-level await (empty if none). +extern "C" BunString Bun__findStalledTopLevelAwait(JSC::JSGlobalObject* globalObject) +{ + StringBuilder builder; + for (auto& [key, entry] : globalObject->moduleLoader()->moduleMap()) { + if (!key.first || !entry) + continue; + auto* record = entry->record(); + if (!record || !record->hasTLA()) + continue; + auto* cyclic = dynamicDowncast(record); + if (!cyclic || cyclic->status() != JSC::CyclicModuleRecord::Status::EvaluatingAsync) + continue; + // Waiting on a dependency: the dependency is the stalled one. + if (auto pending = record->pendingAsyncDependencies(); pending && *pending > 0) + continue; + if (!builder.isEmpty()) + builder.append('\n'); + builder.append(String { key.first }); + } + if (builder.isEmpty()) + return BunStringEmpty; + return Bun::toStringRef(builder.toString()); +} + JSC_DEFINE_HOST_FUNCTION(functionEsmNamespaceForCjs, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto& vm = JSC::getVM(globalObject); diff --git a/src/jsc/event_loop.rs b/src/jsc/event_loop.rs index b1eb57abaa7d..634e9ea61c65 100644 --- a/src/jsc/event_loop.rs +++ b/src/jsc/event_loop.rs @@ -271,6 +271,19 @@ impl Drop for EventLoopEnterGuard { } } +/// Keeps the platform loop ref'd until dropped; construct via [`EventLoop::ref_loop_scoped`]. +#[must_use = "dropping immediately releases the loop ref"] +pub struct LoopRefGuard(*mut uws::Loop); + +impl Drop for LoopRefGuard { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: the per-thread loop outlives the VM; balances the `ref_()` in `ref_loop_scoped`. + unsafe { (*self.0).unref() }; + } + } +} + impl EventLoop { /// Before your code enters JavaScript at the top of the event loop, call /// `loop.enter()`. If running a single callback, prefer `runCallback` instead. @@ -1021,15 +1034,11 @@ impl EventLoop { /// `JsResult` function crosses explicitly with [`jsc::Stopped::throw`] (which, with /// the termination already pending, is just `Thrown`). pub fn wait_for_promise(&mut self, promise: jsc::AnyPromise) -> Result<(), jsc::Stopped> { - let jsc_vm = self.vm_ref().jsc_vm(); if promise.status() != PromiseStatus::Pending { return Ok(()); } while promise.status() == PromiseStatus::Pending { - if jsc_vm.execution_forbidden() - || !self.vm_ref().script_allowed() - || self.global_ref().has_pending_termination_exception() - { + if self.must_stand_down() { return Err(jsc::Stopped); } self.tick(); @@ -1040,6 +1049,57 @@ impl EventLoop { Ok(()) } + /// [`wait_for_promise`](Self::wait_for_promise) that also returns once nothing is left that could settle `promise`. + pub fn wait_for_module_promise( + &mut self, + promise: *mut jsc::JSInternalPromise, + ) -> Result<(), jsc::Stopped> { + while jsc::JSPromise::status_ptr(promise) == PromiseStatus::Pending { + if self.must_stand_down() { + return Err(jsc::Stopped); + } + self.tick(); + if jsc::JSPromise::status_ptr(promise) != PromiseStatus::Pending + || !self.vm_ref().has_pending_loop_work() + { + break; + } + // Ref'd only while parked, so the check above reads the real ref state. + let _parked = self.ref_loop_scoped(); + self.auto_tick(); + } + Ok(()) + } + + /// Ref the loop so `auto_tick` parks until the next event or timer instead of polling; unref'd work still wakes it. + pub fn ref_loop_scoped(&self) -> LoopRefGuard { + let Some(loop_) = self.usockets_loop_opt() else { + return LoopRefGuard(core::ptr::null_mut()); + }; + // SAFETY: the per-thread loop outlives the VM; released by `LoopRefGuard::drop`. + unsafe { (*loop_).ref_() }; + LoopRefGuard(loop_) + } + + fn usockets_loop_opt(&self) -> Option<*mut uws::Loop> { + #[cfg(windows)] + { + self.uws_loop.map(NonNull::as_ptr) + } + #[cfg(not(windows))] + { + self.vm_ref().event_loop_handle + } + } + + /// The conditions under which a wait returns [`jsc::Stopped`]; see [`wait_for_promise`](Self::wait_for_promise). + fn must_stand_down(&self) -> bool { + let vm = self.vm_ref(); + vm.jsc_vm().execution_forbidden() + || !vm.script_allowed() + || self.global_ref().has_pending_termination_exception() + } + pub fn wakeup(&self) { #[cfg(windows)] { diff --git a/src/libuv_sys/libuv.rs b/src/libuv_sys/libuv.rs index 17f19a46b62b..7d126ebe1894 100644 --- a/src/libuv_sys/libuv.rs +++ b/src/libuv_sys/libuv.rs @@ -539,6 +539,13 @@ impl Loop { // SAFETY: self is a live loop. unsafe { uv_loop_alive(self) != 0 } } + /// Whether any I/O handle (socket poll, pipe, tty, process, fs watcher) is open and started, ref'd or not. + pub fn has_active_io_handles(&mut self) -> bool { + let mut found = false; + // SAFETY: self is a live loop; the walk is synchronous, so `found` outlives every callback. + unsafe { uv_walk(self, Some(io_handle_walk_cb), (&raw mut found).cast()) }; + found + } #[inline] pub fn tick(&mut self) { // SAFETY: self is a live loop. @@ -591,6 +598,28 @@ unsafe extern "C" fn close_walk_cb(handle: *mut uv_handle_t, _data: *mut c_void) } } +unsafe extern "C" fn io_handle_walk_cb(handle: *mut uv_handle_t, found: *mut c_void) { + // SAFETY: libuv passes a live handle; `found` is the `bool` local of `has_active_io_handles`. + unsafe { + if uv_is_closing(handle) != 0 || uv_is_active(handle) == 0 { + return; + } + if matches!( + uv_handle_get_type(handle), + HandleType::Poll + | HandleType::NamedPipe + | HandleType::Tty + | HandleType::Process + | HandleType::FsEvent + | HandleType::FsPoll + | HandleType::Tcp + | HandleType::Udp + ) { + *found.cast::() = true; + } + } +} + // ────────────────────────────────────────────────────────────────────────── // Handle mixin — a generic trait every // handle type opts into. All methods are `#[inline]` zero-cost casts. diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 668ed13bdafe..e278cf82d0a0 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -1489,12 +1489,26 @@ impl Run<'_> { vm.event_loop_ref().tick_possibly_forever(); } } else { - while vm.is_event_loop_alive() { - vm.tick(); - vm.auto_tick_active(); + loop { + while vm.is_event_loop_alive() { + vm.tick(); + vm.auto_tick_active(); + } + + vm.on_before_exit(); + + // A beforeExit handler may have resolved the stalled await; wait for the resumed module like the initial load did. + if let Some(entry) = pending_entry_module(vm) { + let _ = vm.wait_for_module_promise(entry); + if pending_entry_module(vm).is_none() { + continue; + } + } + break; } - if ctx.runtime_options.eval.eval_and_print { + // While the entry is still suspended, `entry_point_result` holds the loader's own promise. + if ctx.runtime_options.eval.eval_and_print && pending_entry_module(vm).is_none() { let to_print: JSValue = 'brk: { let result = vm .entry_point_result @@ -1541,7 +1555,30 @@ impl Run<'_> { } } - vm.on_before_exit(); + if let Some(p) = vm.pending_internal_promise { + let promise = bun_jsc::JSInternalPromise::opaque_mut(p); + match promise.status() { + PromiseStatus::Pending => { + vm.report_unsettled_top_level_await(); + if vm.exit_handler.exit_code == 0 { + vm.exit_handler.exit_code = 13; + } + } + // The loader pre-marks this promise handled, so a rejection after beforeExit is only reported here. + PromiseStatus::Rejected + if vm.pending_internal_promise_reported_at != vm.hot_reload_counter => + { + vm.pending_internal_promise_reported_at = vm.hot_reload_counter; + let result = promise.result(vm.jsc_vm()); + let handled = vm.uncaught_exception(vm.global(), result, true); + promise.set_handled(); + if !handled && vm.exit_handler.exit_code == 0 { + vm.exit_handler.exit_code = 1; + } + } + _ => {} + } + } } if log_has_msgs(vm) { @@ -1589,6 +1626,13 @@ fn log_clear_msgs(vm: &mut VirtualMachine) { } } +/// The entry module's evaluation is still suspended on a top-level await. +#[inline] +fn pending_entry_module(vm: &VirtualMachine) -> Option<*mut bun_jsc::JSInternalPromise> { + vm.pending_internal_promise + .filter(|&p| bun_jsc::JSInternalPromise::opaque_ref(p).status() == PromiseStatus::Pending) +} + #[cold] #[inline(never)] #[cfg_attr( diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index afabe9d4128e..527f53263e64 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -3168,6 +3168,40 @@ impl TestCommand { unsafe { (*vm_ptr).run_with_api_lock(|| ctx.begin()) }; } + /// `--bail` reached on a file that failed to load: report, release per-file state, and exit. + fn bail_after_load_failure( + reporter: &mut CommandLineReporter, + vm: &mut VirtualMachine, + bun_test_root_ptr: *mut bun_test::BunTestRoot, + ) -> ! { + reporter.print_summary(); + pretty_error!( + "\nBailed out after {} failure{}\n", + reporter.jest.bail, + if reporter.jest.bail == 1 { "" } else { "s" } + ); + reporter.write_junit_report_if_needed(); + reporter.write_timings_if_needed(); + + vm.exit_handler.exit_code = 1; + vm.is_shutting_down = true; + // `global_exit()` diverges, so the caller's `exit_file()` defer never + // fires. Release the active file's `Strong`s and the preload-hook + // scope here so `Zig__GlobalObject__destructOnExit()`'s `collectNow()` can reclaim them, + // then clear `RUNNER` so finalizers can't observe a partially-torn-down + // `TestRunner`. + // SAFETY: single-threaded; raw-ptr reborrow mirrors the caller's + // `exit_file()` defer escape. + unsafe { + (*bun_test_root_ptr).deinit_for_exit(); + jest::Jest::RUNNER.write(None); + } + let vm_ptr = std::ptr::from_mut::(vm); + // SAFETY: global_exit diverges; `vm_ptr` is a fresh raw-ptr reborrow + // of the exclusive `vm` borrow. + unsafe { (*vm_ptr).run_with_api_lock(|| (&mut *vm_ptr).global_exit()) } + } + pub(crate) fn run( reporter: &mut CommandLineReporter, vm: &mut VirtualMachine, @@ -3296,33 +3330,33 @@ impl TestCommand { reporter.summary().fail += 1; if reporter.jest.bail == reporter.summary().fail { - reporter.print_summary(); - pretty_error!( - "\nBailed out after {} failure{}\n", - reporter.jest.bail, - if reporter.jest.bail == 1 { "" } else { "s" } - ); - reporter.write_junit_report_if_needed(); - reporter.write_timings_if_needed(); - - vm.exit_handler.exit_code = 1; - vm.is_shutting_down = true; - // `global_exit()` diverges, so the `exit_file()` defer - // above never fires. Release the active file's - // `Strong`s and the preload-hook scope here so - // `Zig__GlobalObject__destructOnExit()`'s `collectNow()` can reclaim them, - // then clear `RUNNER` so finalizers can't observe a - // partially-torn-down `TestRunner`. - // SAFETY: single-threaded; raw-ptr reborrow mirrors the - // defer's escape. - unsafe { - (*bun_test_root_ptr).deinit_for_exit(); - jest::Jest::RUNNER.write(None); + Self::bail_after_load_failure(reporter, vm, bun_test_root_ptr); + } + + return Ok(()); + } + jsc::js_promise::Status::Pending => { + reporter.jest.current_file.print_if_needed(); + // `load_preloads` logs which preload is stuck; show that before the generic error. + if let Some(log) = vm.log { + // SAFETY: `vm.log` is the unique per-VM `Box`. + let log = unsafe { &mut *log.as_ptr() }; + if log.errors > 0 { + let _ = log.print(std::ptr::from_mut(Output::error_writer())); + log.msgs.clear(); + log.errors = 0; } - let vm_ptr = std::ptr::from_mut::(vm); - // SAFETY: global_exit diverges; `vm_ptr` is a fresh - // raw-ptr reborrow of the exclusive `vm` borrow. - unsafe { (*vm_ptr).run_with_api_lock(|| (&mut *vm_ptr).global_exit()) }; + } + pretty_errorln!( + "error: Top-level await never resolved while \ + loading {} and nothing is keeping the event loop alive.", + bstr::BStr::new(file_title) + ); + Output::flush(); + reporter.summary().fail += 1; + + if reporter.jest.bail == reporter.summary().fail { + Self::bail_after_load_failure(reporter, vm, bun_test_root_ptr); } return Ok(()); diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 248e0bfe178d..2e55ab0485fd 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -35,8 +35,8 @@ use bun_jsc::virtual_machine::{ InitOptions, RuntimeHooks, RuntimeState as OpaqueRuntimeState, SweepResult, VirtualMachine, }; use bun_jsc::{ - AnyPromise, ErrorCode, ErrorableResolvedSource, JSGlobalObject, JSInternalPromise, - JSModuleLoader, JSValue, JsResult, ResolvedSource, + ErrorCode, ErrorableResolvedSource, JSGlobalObject, JSInternalPromise, JSModuleLoader, JSValue, + JsResult, ResolvedSource, }; use bun_ast::ImportKind; @@ -709,7 +709,7 @@ fn generate_entry_point(_vm: &VirtualMachine, watch: bool, entry_path: &[u8]) -> } /// `loadPreloads()` — runs `--preload` scripts. Returns the first rejected -/// preload promise if any, else null. +/// or still-pending (unsettled top-level await) preload promise, else null. /// /// Error mapping: resolver `Failure` returns the resolver error, /// `Pending`/`NotFound` returns `error.ModuleNotFound`, @@ -718,7 +718,7 @@ fn generate_entry_point(_vm: &VirtualMachine, watch: bool, entry_path: &[u8]) -> /// # Safety /// `vm` is the live per-thread VM. unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JSInternalPromise> { - // Note: reshaped for borrowck — `wait_for_promise` / `event_loop().tick()` + // Note: reshaped for borrowck — `wait_for_module_promise` / `event_loop().tick()` // need `&mut VirtualMachine` while we're also iterating `vm.preload` and // touching `vm.transpiler.resolver` / `vm.log`. Dereference per-field via // the raw `vm` ptr; iterate preloads by index (the `Box<[u8]>` payloads are @@ -845,7 +845,7 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS // ── wait ──────────────────────────────────────────────────────── // HMR `pending_internal_promise` swap loop; non-watcher path uses - // `wait_for_promise` directly. + // `wait_for_module_promise` directly. { // SAFETY: per fn contract. if unsafe { &*vm }.is_watcher_enabled() { @@ -871,20 +871,43 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS if unsafe { &*pip }.status() == PromiseStatus::Pending { // SAFETY: per fn contract — short-lived `&mut *vm` for the // dispatched `auto_tick` hook (same shape as the - // non-watcher `wait_for_promise` arm). + // non-watcher `wait_for_module_promise` arm). unsafe { (*vm).auto_tick() }; } } } else { // SAFETY: per fn contract — short-lived `&mut *vm`; `promise` is a // live protected JSC heap cell. - let _ = unsafe { (*vm).wait_for_promise(AnyPromise::Internal(promise)) }; + let _ = unsafe { (*vm).wait_for_module_promise(promise) }; } } // SAFETY: `promise` is a live (still-protected) JSC heap cell. - if unsafe { &*promise }.status() == PromiseStatus::Rejected { - return Ok(promise); + match unsafe { &*promise }.status() { + PromiseStatus::Fulfilled => {} + PromiseStatus::Rejected => return Ok(promise), + // A wait cut short by a stop request is also `Pending`; that falls through to the stop check below. + // SAFETY: per fn contract. + PromiseStatus::Pending if unsafe { &*vm }.script_allowed() => { + // SAFETY: per fn contract. + if let Some(log) = unsafe { &*vm }.log { + // SAFETY: `preload` points at a live boxed slice for this + // iteration (heap-stable `Box<[u8]>`; nothing above + // mutates `vm.preload`). + let preload_name = unsafe { &*preload }; + // SAFETY: `log` is the unique per-VM `Box`. + let _ = unsafe { &mut *log.as_ptr() }.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "Top-level await in preload {} never resolved", + bun_core::fmt::format_json_string_latin1(preload_name), + ), + ); + } + return Ok(promise); + } + PromiseStatus::Pending => {} } // A stop was requested (worker terminate()/exit) while it loaded: the // caller checks the same and shuts down; load nothing more. @@ -1331,6 +1354,12 @@ fn has_blob_url(blob_id: &[u8]) -> bool { crate::webcore::object_url_registry::ObjectURLRegistry::singleton().has(blob_id) } +fn has_program_timers() -> bool { + let all = timer_all(); + // SAFETY: `timer_all()` is null or the live per-thread `All`; this only reads a counter. + !all.is_null() && unsafe { (*all).has_program_timers() } +} + /// `Response::get_blob_without_call_frame` / /// `Request::get_blob_without_call_frame`. Downcasts /// `value` to a `Response`/`Request` (whose data shapes + `BodyMixin` impl live @@ -1529,6 +1558,7 @@ static __BUN_RUNTIME_HOOKS: RuntimeHooks = RuntimeHooks { ssl_ctx_cache_get_or_create, create_node_fs, has_blob_url, + has_program_timers, body_mixin_get_blob, process_exit, console_on_before_print, diff --git a/src/runtime/timer/mod.rs b/src/runtime/timer/mod.rs index b61ea9c78d9e..7ab901dfe35c 100644 --- a/src/runtime/timer/mod.rs +++ b/src/runtime/timer/mod.rs @@ -292,8 +292,9 @@ impl bun_io::heap::HeapContext for TimerHeapCtx { } } +/// The second field counts linked nodes whose tag is not [`EventLoopTimerTag::is_housekeeping`]. #[derive(Default)] -pub struct TimerHeap(bun_io::heap::Intrusive); +pub struct TimerHeap(bun_io::heap::Intrusive, usize); impl TimerHeap { #[inline] @@ -302,21 +303,33 @@ impl TimerHeap { if r.is_null() { None } else { Some(r) } } + /// Whether a timer that could settle a program's promise is still linked. + #[inline] + pub(crate) fn has_program_timers(&self) -> bool { + self.1 > 0 + } + /// # Safety /// `v` is a valid, exclusively-owned node not currently in any heap /// (its `IntrusiveField` links are null). #[inline] unsafe fn insert(&mut self, v: *mut EventLoopTimer) { - // SAFETY: forwarded — see fn contract. - unsafe { self.0.insert(v) }; + // SAFETY: forwarded — see fn contract; the tag read is on the same live node. + unsafe { + self.1 += usize::from(!(*v).tag.is_housekeeping()); + self.0.insert(v); + } } /// # Safety /// `v` is a node currently in *this* heap. #[inline] unsafe fn remove(&mut self, v: *mut EventLoopTimer) { - // SAFETY: forwarded — see fn contract. - unsafe { self.0.remove(v) }; + // SAFETY: forwarded — see fn contract; the tag read is on the same live node. + unsafe { + self.1 -= usize::from(!(*v).tag.is_housekeeping()); + self.0.remove(v); + } } #[inline] @@ -324,7 +337,12 @@ impl TimerHeap { // SAFETY: all reachable nodes were inserted via `insert()` and remain // live until popped (intrusive invariant maintained by `All`). let r = unsafe { self.0.delete_min() }; - if r.is_null() { None } else { Some(r) } + if r.is_null() { + return None; + } + // SAFETY: `r` was just unlinked and is still a live node. + self.1 -= usize::from(!unsafe { (*r).tag }.is_housekeeping()); + Some(r) } #[inline] @@ -629,6 +647,11 @@ pub(crate) struct All { } impl All { + /// Whether a real-clock timer that could settle a program's promise is pending, ref'd or not. + pub(crate) fn has_program_timers(&self) -> bool { + self.timers.has_program_timers() + } + pub(crate) fn init() -> Self { Self { last_id: 1, diff --git a/src/spawn/process.rs b/src/spawn/process.rs index 932a58a8f762..b4791d6186e2 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -138,10 +138,38 @@ impl Drop for Process { /// The allocation itself is freed by the `heap::take` in `destructor` /// above; this `Drop` body covers the `poller.deinit()` call. fn drop(&mut self) { + #[cfg(unix)] + self.release_waiter_registration(); self.poller.deinit(); } } +#[cfg(unix)] +impl Process { + /// Hand the child to the waiter thread, counting it as registered with the loop (like a poll) until it is reaped or closed. + fn arm_waiter_poller(&mut self) { + if !matches!(self.poller, Poller::WaiterThread(_)) { + self.poller = Poller::WaiterThread(KeepAlive::default()); + // SAFETY: the owning loop outlives every process it watches; balanced by `release_waiter_registration`. + unsafe { (*self.event_loop.platform_event_loop()).inc() }; + } + let ctx = self.event_loop_ctx(); + if let Poller::WaiterThread(w) = &mut self.poller { + w.ref_(ctx); + } + self.ref_(); + WaiterThread::append(self); + } + + /// Balances [`Self::arm_waiter_poller`]; a no-op unless the waiter thread currently holds this child. + fn release_waiter_registration(&self) { + if matches!(self.poller, Poller::WaiterThread(_)) { + // SAFETY: see `arm_waiter_poller`. + unsafe { (*self.event_loop.platform_event_loop()).dec() }; + } + } +} + impl Process { pub fn memory_cost(&self) -> usize { core::mem::size_of::() @@ -283,6 +311,7 @@ impl Process { if let Poller::WaiterThread(waiter) = &mut (*this).poller { let ctx = event_loop_handle_to_ctx((*this).event_loop); waiter.unref(ctx); + (*this).release_waiter_registration(); (*this).poller = Poller::Detached; } } @@ -374,16 +403,11 @@ impl Process { #[cfg(unix)] { - let ctx = self.event_loop_ctx(); if WaiterThread::should_use_waiter_thread() { - self.poller = Poller::WaiterThread(KeepAlive::default()); - if let Poller::WaiterThread(w) = &mut self.poller { - w.ref_(ctx); - } - self.ref_(); - WaiterThread::append(self); + self.arm_waiter_poller(); return Ok(()); } + let ctx = self.event_loop_ctx(); #[cfg(any(target_os = "linux", target_os = "android"))] let watchfd = self.pidfd; @@ -439,16 +463,8 @@ impl Process { #[cfg(unix)] pub(crate) fn rewatch_posix(&mut self) -> bun_sys::Result<()> { - let ctx = self.event_loop_ctx(); if WaiterThread::should_use_waiter_thread() { - if !matches!(self.poller, Poller::WaiterThread(_)) { - self.poller = Poller::WaiterThread(KeepAlive::default()); - } - if let Poller::WaiterThread(w) = &mut self.poller { - w.ref_(ctx); - } - self.ref_(); - WaiterThread::append(self); + self.arm_waiter_poller(); return Ok(()); } @@ -572,6 +588,7 @@ impl Process { poll.deinit(); } else if let Poller::WaiterThread(waiter) = &mut self.poller { waiter.disable(); + self.release_waiter_registration(); } self.poller = Poller::Detached; if stranded_watch_ref && !self.has_exited() { diff --git a/test/regression/issue/19049/19049.test.ts b/test/regression/issue/19049/19049.test.ts new file mode 100644 index 000000000000..90e3d64eef23 --- /dev/null +++ b/test/regression/issue/19049/19049.test.ts @@ -0,0 +1,381 @@ +// https://github.com/oven-sh/bun/issues/19049 +// +// A test file (or entry point) whose top-level await never settles used to +// make `bun test` / `bun run` busy-spin forever once nothing remained to keep +// the event loop alive. Verify we now detect the dead loop, report it, and +// exit, while an await on work that is merely unref'd (a timer, an idle +// connection, an unref'd child) still resolves, as it always has in Bun. + +import { describe, expect, setDefaultTimeout, test } from "bun:test"; +import { bunEnv, bunExe, isLinux, tempDir } from "harness"; + +// Each test spawns a bun subprocess; under the ASAN debug build that's +// several seconds of startup per spawn, which can exceed the 5s default. +setDefaultTimeout(30_000); + +async function run(opts: { cmd: string[]; cwd: string; env?: Record }) { + await using proc = Bun.spawn({ + cmd: opts.cmd, + env: { ...bunEnv, ...opts.env }, + cwd: opts.cwd, + stdout: "pipe", + stderr: "pipe", + // Guard against regressions: the bug manifested as a hang that never + // exits. `await using` will still kill the process if the test itself + // times out, but this keeps the failure fast and self-contained. + timeout: 15_000, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode, signalCode: proc.signalCode }; +} + +// Sequential, not concurrent: each test spawns a bun-debug subprocess (the +// unfixed behaviour is a 100%-CPU busy-spin), and 9 of those at once on an +// ASAN build overwhelm the CI machine and hit the default per-test timeout. +describe("bun test: unsettled top-level await", () => { + test("reports an error instead of hanging (never-resolving Promise)", async () => { + using dir = tempDir("issue-19049-test", { + "hang.test.ts": `await new Promise(() => {});`, + }); + const r = await run({ cmd: [bunExe(), "test", "hang.test.ts"], cwd: String(dir) }); + expect(r.signalCode).toBeNull(); + expect(r.stderr).toContain("Top-level await"); + expect(r.stderr).toContain("never resolved"); + expect(r.stderr).toContain("hang.test.ts"); + expect(r.stderr).toContain("1 fail"); + expect(r.exitCode).toBe(1); + }); + + test("reports an error after a pending timer fires without resolving", async () => { + using dir = tempDir("issue-19049-timer", { + "timer.test.ts": `await new Promise(() => setTimeout(() => {}, 50));`, + }); + const r = await run({ cmd: [bunExe(), "test", "timer.test.ts"], cwd: String(dir) }); + expect(r.signalCode).toBeNull(); + expect(r.stderr).toContain("Top-level await"); + expect(r.stderr).toContain("never resolved"); + expect(r.exitCode).toBe(1); + }); + + test("continues to the next file", async () => { + using dir = tempDir("issue-19049-multi", { + "a.test.ts": `await new Promise(() => {});`, + "b.test.ts": `import { test, expect } from "bun:test"; test("ok", () => expect(1).toBe(1));`, + }); + const r = await run({ cmd: [bunExe(), "test", "a.test.ts", "b.test.ts"], cwd: String(dir) }); + expect(r.signalCode).toBeNull(); + expect(r.stderr).toContain("Top-level await"); + expect(r.stderr).toContain("1 pass"); + expect(r.stderr).toContain("1 fail"); + expect(r.exitCode).toBe(1); + }); + + test("an unhandled rejection in one file does not taint async TLA in a later file", async () => { + // unhandled_error_counter persists across files; the liveness check in + // waitForModulePromise must not short-circuit on it or b's perfectly + // valid `await setTimeout` is misreported as "never resolved". + using dir = tempDir("issue-19049-crossfile", { + "a.test.ts": `import { test } from "bun:test"; Promise.reject(new Error("boom")); test("a", () => {});`, + "b.test.ts": `import { test, expect } from "bun:test"; await new Promise(r => setTimeout(r, 10)); test("b", () => expect(1).toBe(1));`, + }); + const r = await run({ cmd: [bunExe(), "test", "./a.test.ts", "./b.test.ts"], cwd: String(dir) }); + expect(r.signalCode).toBeNull(); + // b's TLA must complete; only a's unhandled rejection is the error. + expect(r.stderr).not.toContain("Top-level await"); + expect(r.stderr).toContain("(pass) b"); + expect(r.stderr).toContain("error: boom"); + expect(r.exitCode).toBe(1); + }); + + test("original repro: mock.module + preload", async () => { + using dir = tempDir("issue-19049-original", { + "preload.ts": ` +import { mock } from "bun:test"; +mock.module("node:http2", () => ({ default: { connect: mock() } })); +`, + "bad.test.ts": ` +import { mock } from "bun:test"; +import http2 from "node:http2"; + +mock.module("node:http2", () => ({ + default: { + connect: mock().mockReturnValue({ + request: mock(() => setTimeout(() => {}, 50)), + }), + }, +})); + +await new Promise(() => http2.connect("foo").request()); +`, + }); + const r = await run({ + cmd: [bunExe(), "test", "--preload", "./preload.ts", "bad.test.ts"], + cwd: String(dir), + }); + expect(r.signalCode).toBeNull(); + expect(r.stderr).toContain("Top-level await"); + expect(r.stderr).toContain("never resolved"); + expect(r.exitCode).toBe(1); + }); + + test("--bail bails out after an unsettled TLA failure", async () => { + using dir = tempDir("issue-19049-bail", { + "hang.test.ts": `await new Promise(() => {});`, + }); + const r = await run({ cmd: [bunExe(), "test", "--bail", "hang.test.ts"], cwd: String(dir) }); + expect(r.signalCode).toBeNull(); + expect(r.stderr).toContain("Top-level await"); + expect(r.stderr).toContain("Bailed out after 1 failure"); + expect(r.exitCode).toBe(1); + }); + + test("a --preload with unsettled TLA is named in the error", async () => { + using dir = tempDir("issue-19049-test-preload", { + "preload.mjs": `await new Promise(() => {});`, + "ok.test.ts": `import { test } from "bun:test"; test("unreachable", () => {});`, + }); + const r = await run({ cmd: [bunExe(), "test", "--preload", "./preload.mjs", "ok.test.ts"], cwd: String(dir) }); + expect(r.signalCode).toBeNull(); + expect(r.stderr).toContain(`Top-level await in preload "./preload.mjs" never resolved`); + expect(r.stderr).toContain("Top-level await never resolved while loading"); + expect(r.stderr).not.toContain("unreachable"); + expect(r.exitCode).toBe(1); + }); + + test("an await on an unref'd timer still resolves", async () => { + using dir = tempDir("issue-19049-test-unref", { + "unref.test.ts": ` +import { test, expect } from "bun:test"; +const fired = await new Promise(resolve => setTimeout(() => resolve(true), 20).unref()); +test("fired", () => expect(fired).toBe(true)); +`, + }); + const r = await run({ cmd: [bunExe(), "test", "unref.test.ts"], cwd: String(dir) }); + expect(r.signalCode).toBeNull(); + expect(r.stderr).not.toContain("Top-level await"); + expect(r.stderr).toContain("1 pass"); + expect(r.exitCode).toBe(0); + }); +}); + +describe("bun run: unsettled top-level await", () => { + test("warns and exits with code 13", async () => { + using dir = tempDir("issue-19049-run", { + "entry.mjs": `await new Promise(() => {});\nconsole.log("unreachable");`, + }); + const r = await run({ cmd: [bunExe(), "entry.mjs"], cwd: String(dir) }); + expect(r.signalCode).toBeNull(); + expect(r.stderr).toContain("unsettled top-level await"); + expect(r.stdout).not.toContain("unreachable"); + expect(r.exitCode).toBe(13); + }); + + test("warns and exits with code 13 when a sub-import has unsettled TLA", async () => { + using dir = tempDir("issue-19049-subimport", { + "sub.mjs": `await new Promise(() => {});`, + "entry.mjs": `import "./sub.mjs";\nconsole.log("unreachable");`, + }); + const r = await run({ cmd: [bunExe(), "entry.mjs"], cwd: String(dir) }); + expect(r.signalCode).toBeNull(); + expect(r.stderr).toContain("unsettled top-level await"); + expect(r.stdout).not.toContain("unreachable"); + expect(r.exitCode).toBe(13); + }); + + test("warning names the stalled module, not the entry", async () => { + using dir = tempDir("issue-19049-deep", { + "leaf.mjs": `await new Promise(() => {});`, + "mid.mjs": `import "./leaf.mjs";`, + "entry.mjs": `import "./mid.mjs";`, + }); + const r = await run({ cmd: [bunExe(), "entry.mjs"], cwd: String(dir) }); + expect(r.signalCode).toBeNull(); + expect(r.stderr).toContain("unsettled top-level await"); + // The warning should point at leaf.mjs (the module actually suspended + // on its own await), not the entry or the intermediate import. + expect(r.stderr).toContain("leaf.mjs"); + expect(r.stderr).not.toContain("entry.mjs"); + expect(r.stderr).not.toContain("mid.mjs"); + expect(r.exitCode).toBe(13); + }); + + test("one warning line per stalled sibling module", async () => { + using dir = tempDir("issue-19049-siblings", { + "a.mjs": `await new Promise(() => {});`, + "b.mjs": `await new Promise(() => {});`, + "entry.mjs": `import "./a.mjs";\nimport "./b.mjs";`, + }); + const r = await run({ cmd: [bunExe(), "entry.mjs"], cwd: String(dir) }); + expect(r.signalCode).toBeNull(); + // Both siblings are stalled on their own await; each gets its own + // warning line, like Node. (Module-map order is not source order, so + // don't assert which comes first.) + const warnings = r.stderr.split(/\r?\n/).filter(l => l.includes("Detected unsettled top-level await")); + expect(warnings).toHaveLength(2); + expect(warnings.join("\n")).toContain("a.mjs"); + expect(warnings.join("\n")).toContain("b.mjs"); + expect(r.exitCode).toBe(13); + }); + + test("warns and exits with code 13 when a --preload has unsettled TLA", async () => { + using dir = tempDir("issue-19049-preload", { + "preload.mjs": `await new Promise(() => {});`, + "entry.mjs": `console.log("unreachable");`, + }); + const r = await run({ cmd: [bunExe(), "--preload", "./preload.mjs", "entry.mjs"], cwd: String(dir) }); + expect(r.signalCode).toBeNull(); + // The error should name the preload, not just the entry. + expect(r.stderr).toContain(`Top-level await in preload "./preload.mjs" never resolved`); + expect(r.stderr).toContain("unsettled top-level await"); + expect(r.stdout).not.toContain("unreachable"); + expect(r.exitCode).toBe(13); + }); + + test("beforeExit fires first and can resolve the await", async () => { + using dir = tempDir("issue-19049-beforeexit", { + "entry.mjs": ` +let resolve; +const p = new Promise(r => { resolve = r; }); +process.on("beforeExit", () => { console.log("beforeExit"); resolve(); }); +await p; +console.log("after await"); +`, + }); + const r = await run({ cmd: [bunExe(), "entry.mjs"], cwd: String(dir) }); + expect(r.signalCode).toBeNull(); + expect(r.stdout).toContain("beforeExit"); + expect(r.stdout).toContain("after await"); + expect(r.exitCode).toBe(0); + }); + + test("--print with unsettled TLA warns without printing the internal promise", async () => { + using dir = tempDir("issue-19049-print", {}); + const r = await run({ cmd: [bunExe(), "-p", "await new Promise(() => {})"], cwd: String(dir) }); + expect(r.signalCode).toBeNull(); + expect(r.stderr).toContain("unsettled top-level await"); + // `entry_point_result` holds the module pipeline's internal promise when + // the module never settles; it must not leak to stdout as + // "Promise { }". + expect(r.stdout).toBe(""); + expect(r.exitCode).toBe(13); + }); + + test("--print still prints settled TLA values and user pending promises", async () => { + using dir = tempDir("issue-19049-print-ok", {}); + const settled = await run({ cmd: [bunExe(), "-p", "await Promise.resolve(42)"], cwd: String(dir) }); + expect(settled.stdout).toBe("42\n"); + expect(settled.exitCode).toBe(0); + // A pending promise the *user* evaluated to (the module itself settles) + // still prints, matching `node -p`. + const userPending = await run({ cmd: [bunExe(), "-p", "new Promise(() => {})"], cwd: String(dir) }); + expect(userPending.stdout).toBe("Promise { }\n"); + expect(userPending.exitCode).toBe(0); + }); +}); + +// Unlike Node (which exits 13 here), Bun waits for work that is registered +// but unref'd; detection only triggers when nothing at all is left that could +// fire. These pin that, and that the wait parks instead of spinning a core. +describe("bun run: awaits on unref'd work still resolve", () => { + test("unref'd setTimeout fires, without busy-waiting", async () => { + using dir = tempDir("issue-19049-unref-timer", { + "entry.mjs": ` +const before = process.cpuUsage(); +await new Promise(resolve => setTimeout(resolve, 300).unref()); +const { user, system } = process.cpuUsage(before); +console.log(JSON.stringify({ cpuMs: (user + system) / 1000 })); +`, + }); + const r = await run({ cmd: [bunExe(), "entry.mjs"], cwd: String(dir) }); + expect(r.stderr).toBe(""); + // A busy-wait burns the whole 300ms of wall time as CPU; parking burns a + // few wake-ups' worth. 50% leaves room for GC threads on a loaded ASAN + // lane, matching the other cpuUsage()-based spin tests in the tree. + expect(JSON.parse(r.stdout).cpuMs).toBeLessThan(150); + expect(r.exitCode).toBe(0); + }); + + test("AbortSignal.timeout fires", async () => { + using dir = tempDir("issue-19049-unref-abort", { + "entry.mjs": ` +const reason = await new Promise(resolve => { + const signal = AbortSignal.timeout(10); + signal.addEventListener("abort", () => resolve(signal.reason.name)); +}); +console.log(reason); +`, + }); + const r = await run({ cmd: [bunExe(), "entry.mjs"], cwd: String(dir) }); + expect({ stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode }).toEqual({ + stdout: "TimeoutError\n", + stderr: "", + exitCode: 0, + }); + }); + + const unrefChild = { + "entry.mjs": ` +const child = Bun.spawn({ cmd: [process.execPath, "-e", ""], stdio: ["ignore", "ignore", "ignore"] }); +child.unref(); +console.log("exit code", await child.exited); +`, + }; + const resolved = { stdout: "exit code 0\n", stderr: "", exitCode: 0 }; + + test("an unref'd child process's exit is still observed", async () => { + using dir = tempDir("issue-19049-unref-child", unrefChild); + const r = await run({ cmd: [bunExe(), "entry.mjs"], cwd: String(dir) }); + expect({ stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode }).toEqual(resolved); + }); + + // Kernels and sandboxes without pidfd watch children from a helper thread + // instead of a poll; the child must still count as registered there. + test.skipIf(!isLinux)("an unref'd child watched by the waiter thread is still observed", async () => { + using dir = tempDir("issue-19049-unref-child-waiter", unrefChild); + const r = await run({ + cmd: [bunExe(), "entry.mjs"], + cwd: String(dir), + env: { BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1", BUN_GARBAGE_COLLECTOR_LEVEL: "1" }, + }); + expect({ stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode }).toEqual(resolved); + }); + + test.skipIf(!isLinux)("reaped waiter-thread children no longer count as pending work", async () => { + using dir = tempDir("issue-19049-waiter-balance", { + "entry.mjs": ` +for (let i = 0; i < 3; i++) { + const child = Bun.spawn({ cmd: [process.execPath, "-e", ""], stdio: ["ignore", "ignore", "ignore"] }); + child.unref(); + await child.exited; +} +await new Promise(() => {}); +`, + }); + const r = await run({ + cmd: [bunExe(), "entry.mjs"], + cwd: String(dir), + env: { BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1", BUN_GARBAGE_COLLECTOR_LEVEL: "1" }, + }); + expect(r.signalCode).toBeNull(); + expect(r.stderr).toContain("unsettled top-level await"); + expect(r.exitCode).toBe(13); + }); + + test("a module resumed by beforeExit can go on to await unref'd work", async () => { + using dir = tempDir("issue-19049-beforeexit-unref", { + "entry.mjs": ` +const { promise, resolve } = Promise.withResolvers(); +process.on("beforeExit", resolve); +await promise; +await new Promise(r => setTimeout(r, 10).unref()); +console.log("done"); +`, + }); + const r = await run({ cmd: [bunExe(), "entry.mjs"], cwd: String(dir) }); + expect({ stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode }).toEqual({ + stdout: "done\n", + stderr: "", + exitCode: 0, + }); + }); +});