Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 69 additions & 14 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1186,20 +1186,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 {
Expand All @@ -1209,6 +1212,14 @@ impl VirtualMachine {
|| !el.next_immediate_tasks.is_empty()
}

/// `is_event_loop_alive` minus the `unhandled_error_counter` check, which 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()
}

pub fn wakeup(&mut self) {
self.event_loop_mut().wakeup();
}
Expand Down Expand Up @@ -2042,7 +2053,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>,
Expand Down Expand Up @@ -2629,6 +2640,50 @@ impl VirtualMachine {
self.event_loop_mut().wait_for_promise(promise)
}

/// `wait_for_promise` that also returns once nothing is left that could settle `promise` (unsettled top-level await).
pub fn wait_for_module_promise(
&mut self,
promise: *mut JSInternalPromise,
) -> Result<(), jsc::Stopped> {
while crate::JSPromise::status_ptr(promise) == crate::js_promise::Status::Pending {
if self.jsc_vm().execution_forbidden() || !self.script_allowed() {
return Err(jsc::Stopped);
}
self.event_loop_mut().tick();
if crate::JSPromise::status_ptr(promise) != crate::js_promise::Status::Pending
|| !self.has_pending_loop_work()
{
break;
}
self.auto_tick();
}
Ok(())
}

/// 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!(
"<r><yellow>Warning<r><d>:<r> Detected unsettled top-level await at <b>{}<r>",
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]
Expand Down Expand Up @@ -2801,7 +2856,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],
Expand Down Expand Up @@ -2835,7 +2890,7 @@ impl VirtualMachine {
return Ok(promise);
}
self.event_loop_mut().perform_gc();
let _ = self.wait_for_promise(jsc::AnyPromise::Internal(promise));
let _ = self.wait_for_module_promise(promise);
Comment thread
claude[bot] marked this conversation as resolved.
}

Ok(self.pending_internal_promise.unwrap_or(promise))
Expand Down Expand Up @@ -4887,7 +4942,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],
Expand Down Expand Up @@ -4920,7 +4975,7 @@ impl VirtualMachine {
return Ok(promise);
}
self.event_loop_mut().perform_gc();
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).
Expand Down
25 changes: 25 additions & 0 deletions src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,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<JSC::CyclicModuleRecord>(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 });
Comment thread
claude[bot] marked this conversation as resolved.
}
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);
Expand Down
55 changes: 50 additions & 5 deletions src/runtime/cli/run_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1485,12 +1485,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; drain its microtask and go again.
if entry_module_pending(vm) {
vm.tick();
if vm.is_event_loop_alive() {
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 && !entry_module_pending(vm) {
let to_print: JSValue = 'brk: {
let result = vm
.entry_point_result
Expand Down Expand Up @@ -1537,7 +1551,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) {
Expand Down Expand Up @@ -1585,6 +1622,14 @@ fn log_clear_msgs(vm: &mut VirtualMachine) {
}
}

/// The entry module's evaluation is still suspended on a top-level await.
#[inline]
fn entry_module_pending(vm: &VirtualMachine) -> bool {
vm.pending_internal_promise.is_some_and(|p| {
bun_jsc::JSInternalPromise::opaque_ref(p).status() == PromiseStatus::Pending
})
}

#[cold]
#[inline(never)]
#[cfg_attr(
Expand Down
86 changes: 60 additions & 26 deletions src/runtime/cli/test_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3166,6 +3166,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{}<r>\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::<VirtualMachine>(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,
Expand Down Expand Up @@ -3294,33 +3328,33 @@ impl TestCommand {
reporter.summary().fail += 1;

if reporter.jest.bail == reporter.summary().fail {
reporter.print_summary();
pretty_error!(
"\nBailed out after {} failure{}<r>\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<Log>`.
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::<VirtualMachine>(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!(
"<r><red>error<r><d>:<r> Top-level await never resolved while \
loading <b>{}<r> 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);
}
Comment thread
robobun marked this conversation as resolved.

return Ok(());
Expand Down
Loading
Loading