diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 6cf416165611..2e7d6d5e8b47 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1041,6 +1041,24 @@ impl VirtualMachine { || !el.next_immediate_tasks.is_empty() } + /// Whether anything could still wake the loop and settle a pending module + /// promise. Unlike `is_event_loop_alive`, ignores `unhandled_error_counter` + /// so ref'd work (e.g. a timer that will resolve the await) isn't miscounted. + pub fn has_pending_loop_work(&self) -> bool { + let el = self.event_loop_shared(); + let active = self + .platform_loop_opt() + .map(|h| h.is_active()) + .unwrap_or(false); + active + || self.active_tasks > 0 + || el.tasks.readable_length() > 0 + || el.has_pending_refs() + || !el.concurrent_tasks.is_empty() + || !el.immediate_tasks.is_empty() + || !el.next_immediate_tasks.is_empty() + } + pub fn wakeup(&mut self) { self.event_loop_mut().wakeup(); } @@ -2232,6 +2250,77 @@ impl VirtualMachine { self.event_loop_mut().wait_for_promise(promise); } + /// Like [`wait_for_promise`](Self::wait_for_promise) but returns (promise + /// possibly still `Pending`) once nothing could settle it + /// (`!has_pending_loop_work`), instead of spinning on an unsettled TLA. + pub fn wait_for_module_promise(&mut self, promise: *mut JSInternalPromise) { + // Read as a raw ptr (Copy) so it doesn't borrow `self` across the + // `&mut self` calls (`tick`, `auto_tick`) below. + let jsc_vm = self.jsc_vm; + // SAFETY: `promise` is a live JSC heap cell tracked by the VM (caller + // just obtained it from `reload_entry_point`). + while crate::JSPromise::status_ptr(promise) == crate::js_promise::Status::Pending { + // SAFETY: `jsc_vm` is the live per-thread JSC VM (set in `init`). + if unsafe { &*jsc_vm }.execution_forbidden() { + return; + } + self.event_loop_mut().tick(); + if crate::JSPromise::status_ptr(promise) != crate::js_promise::Status::Pending { + return; + } + // Nothing left that could settle the promise: return instead of + // busy-spinning (see `has_pending_loop_work`). + if !self.has_pending_loop_work() { + return; + } + self.auto_tick(); + } + } + + /// True when the entry module's evaluation promise is still pending, i.e. + /// (once the loop has drained) an unsettled top-level await. + pub fn entry_point_evaluation_is_pending(&self) -> bool { + match self.pending_internal_promise { + Some(p) => crate::JSPromise::status_ptr(p) == crate::js_promise::Status::Pending, + None => false, + } + } + + /// Print Node's "Detected unsettled top-level await" warning to stderr, + /// naming the stalled module(s) from the JSC module registry (falling back + /// to the entry path in eval mode). + 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 object. + let stalled = unsafe { Bun__findStalledTopLevelAwait(self.global) }; + let stalled_utf8 = stalled.to_utf8(); + let slice = stalled_utf8.slice(); + let warn = |module: &[u8]| { + bun_core::pretty_errorln!( + "Warning: Detected unsettled top-level await at {}", + bstr::BStr::new(module), + ); + }; + if !slice.is_empty() { + // The C++ helper NUL-joins multiple stalled specifiers (NUL can't + // appear in a path); print one warning per module, matching Node. + for module in slice.split(|&b| b == b'\0') { + warn(module); + } + } else if !self.main().is_empty() { + warn(self.main()); + } else { + bun_core::pretty_errorln!( + "Warning: Detected unsettled top-level await" + ); + } + bun_core::Output::flush(); + drop(stalled_utf8); + stalled.deref(); + } + /// `eventLoop().autoTick()` — dispatched through the runtime hook /// (needs `Timer::All` for the poll timeout). #[inline] @@ -2434,7 +2523,9 @@ impl VirtualMachine { return Ok(promise); } self.event_loop_mut().perform_gc(); - self.wait_for_promise(jsc::AnyPromise::Internal(promise)); + // Returns with the promise still pending if the loop drains, so the + // caller can detect an unsettled top-level await (warn + exit 13). + self.wait_for_module_promise(promise); } Ok(self.pending_internal_promise.unwrap_or(promise)) diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index b2b5535f071b..c7bb3d0a724e 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -38,6 +38,7 @@ #include "JavaScriptCore/JSModuleLoader.h" #include "JavaScriptCore/CyclicModuleRecord.h" #include "JavaScriptCore/ModuleRegistryEntry.h" +#include #include "JavaScriptCore/JSModuleNamespaceObject.h" #include "JavaScriptCore/JSModuleNamespaceObjectInlines.h" #include "JavaScriptCore/JSModuleRecord.h" @@ -719,6 +720,35 @@ static bool isModuleEvaluated(JSC::AbstractModuleRecord* record) return record->moduleEnvironmentMayBeNull() != nullptr; } +// Module specifiers suspended on their own top-level await (EvaluatingAsync, +// syntactic TLA, no pending async dependency), NUL-joined, for the +// unsettled-TLA warning. Empty BunString when nothing is stalled. +extern "C" BunString Bun__findStalledTopLevelAwait(JSC::JSGlobalObject* globalObject) +{ + WTF::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; + // EvaluatingAsync only because it awaits a dependency: that dependency + // is the real culprit, skip this one. + if (auto pending = record->pendingAsyncDependencies(); pending && *pending > 0) + continue; + // NUL separator: it cannot appear in a module specifier/path. + if (!builder.isEmpty()) + builder.append('\0'); + 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/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 655a169642b7..8d5eafd5d733 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -1581,54 +1581,78 @@ impl Run { vm.auto_tick_active(); } - if ctx.runtime_options.eval.eval_and_print { - let to_print: JSValue = 'brk: { - let result = vm - .entry_point_result - .value - .get() - .unwrap_or(JSValue::UNDEFINED); - if let Some(promise) = result.as_any_promise() { - match promise.status() { - PromiseStatus::Pending => { - // C-ABI shims are emitted by - // `generate-host-exports.ts` into - // `crate::generated_host_exports` under their - // link name (`Bun__on…EntryPointResult`). - result.then2( - vm.global(), - JSValue::UNDEFINED, - crate::generated_host_exports::Bun__onResolveEntryPointResult, - crate::generated_host_exports::Bun__onRejectEntryPointResult, - ); - vm.tick(); - vm.auto_tick_active(); - while vm.is_event_loop_alive() { - vm.tick(); - vm.auto_tick_active(); - } - break 'brk result; - } - _ => break 'brk promise.result(vm.jsc_vm()), + // A settled entry prints its `--print` value before `beforeExit` + // (Node's order: value then beforeExit output); a pending/rejected + // entry prints nothing (a bogus `Promise { }`; reported below). + let eval_and_print = ctx.runtime_options.eval.eval_and_print; + let printed = eval_and_print && entry_point_print_ok(vm); + if printed { + print_eval_result(vm); + } + + vm.on_before_exit(); + + // A `beforeExit` handler may resolve the entry's await (a bare + // `resolve()` queues a microtask `is_event_loop_alive()` ignores); + // drain it, and re-run the loop if the resumed body schedules work. + while vm.entry_point_evaluation_is_pending() { + vm.tick(); + if !vm.is_event_loop_alive() { + break; + } + while vm.is_event_loop_alive() { + vm.tick(); + vm.auto_tick_active(); + } + vm.on_before_exit(); + } + + // A `beforeExit` handler that just resolved the TLA prints its value + // now (a still-pending/rejected entry is reported below instead). + if eval_and_print && !printed && entry_point_print_ok(vm) { + print_eval_result(vm); + } + + // The entry promise is pre-marked handled, so report it here: + // pending (unsettled TLA) → exit 13; late-rejected (resumed body + // threw) → exit 1, gated so an initial-load rejection isn't redone. + if let Some(p) = vm.pending_internal_promise { + // SAFETY: `p` is a live JSC heap cell tracked by the VM. + match bun_jsc::JSPromise::status_ptr(p) { + PromiseStatus::Pending => { + vm.report_unsettled_top_level_await(); + if vm.exit_handler.exit_code == 0 { + vm.exit_handler.exit_code = 13; } } - result - }; - // SAFETY: `vals[..1]` is the single stack `to_print`; null - // `ctype` routes to the VM's stdout/stderr default. - unsafe { - bun_jsc::ConsoleObject::message_with_type_and_level( - ::core::ptr::null_mut(), - bun_jsc::ConsoleObject::MessageType::Log, - bun_jsc::ConsoleObject::MessageLevel::Log, - vm.global(), - &raw const to_print, - 1, - ); + PromiseStatus::Rejected + if vm.pending_internal_promise_reported_at != vm.hot_reload_counter => + { + vm.pending_internal_promise_reported_at = vm.hot_reload_counter; + // `on_before_exit` set `exit_on_uncaught_exception`, which + // hard-exits before a user `uncaughtException` handler; + // clear it so the throw reaches it (Node: handler -> 0). + vm.exit_on_uncaught_exception = false; + // SAFETY: `p` is a live JSC heap cell; `vm.jsc_vm` set in `init`. + let result = unsafe { &mut *p }.result(unsafe { &mut *vm.jsc_vm }); + let global = vm.global; + // SAFETY: `global` valid for VM lifetime. `uncaught_exception` + // runs a user handler (exit 0) and sets exit_code = 1 itself + // when unhandled. + let _ = vm.uncaught_exception(unsafe { &*global }, result, true); + // SAFETY: `p` is a live JSC heap cell. + unsafe { &mut *p }.set_handled(); + } + _ => {} } } - vm.on_before_exit(); + // An `uncaughtException` handler above may have scheduled async work + // (e.g. a timer); drain it, mirroring the initial-load path. + while vm.is_event_loop_alive() { + vm.tick(); + vm.auto_tick_active(); + } } if log_has_msgs(vm) { @@ -1658,6 +1682,61 @@ impl Run { } } +/// Whether the entry module's `--print` value is ready to print: the entry +/// promise is absent (e.g. patched runMain) or fulfilled. A pending (unsettled +/// TLA) or rejected entry prints nothing; it is reported (exit 13 / 1) instead. +fn entry_point_print_ok(vm: &VirtualMachine) -> bool { + vm.pending_internal_promise + .is_none_or(|p| bun_jsc::JSPromise::status_ptr(p) == PromiseStatus::Fulfilled) +} + +/// Print the `--print`/`-p` eval result: the entry module's completion value, +/// unwrapping the pipeline promise (draining the loop if it is still pending). +fn print_eval_result(vm: &mut VirtualMachine) { + let to_print: JSValue = 'brk: { + let result = vm + .entry_point_result + .value + .get() + .unwrap_or(JSValue::UNDEFINED); + if let Some(promise) = result.as_any_promise() { + match promise.status() { + PromiseStatus::Pending => { + // C-ABI shims are emitted by `generate-host-exports.ts` into + // `crate::generated_host_exports` under their link name. + result.then2( + vm.global(), + JSValue::UNDEFINED, + crate::generated_host_exports::Bun__onResolveEntryPointResult, + crate::generated_host_exports::Bun__onRejectEntryPointResult, + ); + vm.tick(); + vm.auto_tick_active(); + while vm.is_event_loop_alive() { + vm.tick(); + vm.auto_tick_active(); + } + break 'brk result; + } + _ => break 'brk promise.result(vm.jsc_vm()), + } + } + result + }; + // SAFETY: `vals[..1]` is the single stack `to_print`; null `ctype` routes + // to the VM's stdout/stderr default. + unsafe { + bun_jsc::ConsoleObject::message_with_type_and_level( + ::core::ptr::null_mut(), + bun_jsc::ConsoleObject::MessageType::Log, + bun_jsc::ConsoleObject::MessageLevel::Log, + vm.global(), + &raw const to_print, + 1, + ); + } +} + #[inline] fn log_has_msgs(vm: &VirtualMachine) -> bool { match vm.log { diff --git a/test/js/node/process/unsettled-top-level-await.test.ts b/test/js/node/process/unsettled-top-level-await.test.ts new file mode 100644 index 000000000000..875d497f7d8b --- /dev/null +++ b/test/js/node/process/unsettled-top-level-await.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, setDefaultTimeout, test } from "bun:test"; +import { bunEnv, bunExe, isDebug, tempDir } from "harness"; + +// These tests spawn bun concurrently; under debug+ASAN a spawn takes seconds +// and contention pushes some past the 5s default, so use a generous per-test +// timeout. SPAWN_TIMEOUT bounds the pre-fix hang (killed -> non-null signal). +const SPAWN_TIMEOUT = 20_000; +setDefaultTimeout(isDebug ? 60_000 : 30_000); + +// https://github.com/oven-sh/bun/issues/33283 +// Node warns and exits 13 on an unsettled entry top-level await instead of +// hanging; the spawn `timeout` turns the pre-fix hang into a clean failure. + +async function run(files: Record, entry: string) { + using dir = tempDir("unsettled-tla", { "package.json": "{}", ...files }); + await using proc = Bun.spawn({ + cmd: [bunExe(), entry], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + timeout: SPAWN_TIMEOUT, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // Hang guard: the process must exit on its own, not be killed by the spawn + // timeout (a reintroduced busy-spin would surface as a non-null signal here). + expect(proc.signalCode).toBeNull(); + return { stdout, stderr, exitCode }; +} + +describe.concurrent("unsettled top-level await", () => { + test("await on a never-resolving promise exits 13", async () => { + const r = await run( + { + "entry.mjs": `console.log("BEFORE");\nawait new Promise(() => {});\nconsole.log("AFTER");\n`, + }, + "./entry.mjs", + ); + expect(r.stdout).toBe("BEFORE\n"); + expect(r.stderr).toContain("Detected unsettled top-level await"); + expect(r.exitCode).toBe(13); + }); + + test("dynamic-import top-level-await cycle exits 13", async () => { + const r = await run( + { + "a.mjs": `import "./b.mjs";\n`, + "b.mjs": `console.log("B_BEFORE");\nawait import("./a.mjs");\nconsole.log("B_AFTER");\n`, + }, + "./a.mjs", + ); + expect(r.stdout).toBe("B_BEFORE\n"); + expect(r.stderr).toContain("Detected unsettled top-level await"); + expect(r.exitCode).toBe(13); + }); + + test("self-import via import.meta.url exits 13", async () => { + // `await import(import.meta.url)` awaits the entry's own evaluation + // promise: a spec-level deadlock, not a bun bug, but bun must detect it + // and exit 13 rather than hang. + const r = await run( + { + "entry.mjs": `console.log("BEFORE");\nawait import(import.meta.url);\nconsole.log("AFTER");\n`, + }, + "./entry.mjs", + ); + expect(r.stdout).toBe("BEFORE\n"); + expect(r.stderr).toContain("Detected unsettled top-level await"); + expect(r.stderr).toContain("entry.mjs"); + expect(r.exitCode).toBe(13); + }); + + test("await on an unref'd timer exits 13", async () => { + const r = await run( + { + "entry.mjs": `await new Promise(r => setTimeout(r, 100000).unref());\n`, + }, + "./entry.mjs", + ); + expect(r.stderr).toContain("Detected unsettled top-level await"); + expect(r.exitCode).toBe(13); + }); + + test("a ref'd timer keeps the loop alive and the await settles (exit 0)", async () => { + const r = await run( + { + "entry.mjs": `await new Promise(r => setTimeout(r, 50));\nconsole.log("DONE");\n`, + }, + "./entry.mjs", + ); + expect(r.stdout).toBe("DONE\n"); + expect(r.stderr).not.toContain("Detected unsettled top-level await"); + expect(r.exitCode).toBe(0); + }); + + test("a beforeExit handler can settle the await (exit 0)", async () => { + // Node parity: a beforeExit handler that resolves the awaited promise lets + // the entry resume instead of triggering exit 13. + const r = await run( + { + "entry.mjs": ` + let resolve; + process.on("beforeExit", () => { console.log("beforeExit"); resolve(); }); + await new Promise(r => { resolve = r; }); + console.log("DONE"); + `, + }, + "./entry.mjs", + ); + expect(r.stdout).toBe("beforeExit\nDONE\n"); + expect(r.stderr).not.toContain("Detected unsettled top-level await"); + expect(r.exitCode).toBe(0); + }); + + test("warning names the stalled module, not the entry", async () => { + const r = await run( + { + "entry.mjs": `import "./mid.mjs";\n`, + "mid.mjs": `import "./leaf.mjs";\n`, + "leaf.mjs": `await new Promise(() => {});\n`, + }, + "./entry.mjs", + ); + expect(r.stderr).toContain("Detected unsettled top-level await"); + 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("warning lists every stalled sibling", async () => { + const r = await run( + { + "entry.mjs": `import "./sib1.mjs";\nimport "./sib2.mjs";\n`, + "sib1.mjs": `await new Promise(() => {});\n`, + "sib2.mjs": `await new Promise(() => {});\n`, + }, + "./entry.mjs", + ); + expect(r.stderr).toContain("Detected unsettled top-level await"); + expect(r.stderr).toContain("sib1.mjs"); + expect(r.stderr).toContain("sib2.mjs"); + expect(r.stderr).not.toContain("entry.mjs"); + expect(r.exitCode).toBe(13); + }); + + test("--print prints a top-level await that beforeExit settles", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-p", `let r; process.on("beforeExit", () => r(42)); await new Promise(res => { r = res; });`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: SPAWN_TIMEOUT, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(proc.signalCode).toBeNull(); + expect(stdout.trim()).toBe("42"); + expect(stderr).not.toContain("Detected unsettled top-level await"); + expect(exitCode).toBe(0); + }); + + test("--print emits the value before beforeExit output for a non-TLA entry", async () => { + // A synchronously-settled entry prints before beforeExit (Node's order), + // exercising the print-before-beforeExit path. + await using proc = Bun.spawn({ + cmd: [bunExe(), "-p", `process.on("beforeExit", () => console.log("BE")); 123`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: SPAWN_TIMEOUT, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(proc.signalCode).toBeNull(); + expect(stdout).toBe("123\nBE\n"); + expect(exitCode).toBe(0); + }); + + test("a resumed body that throws after beforeExit exits 1", async () => { + const r = await run( + { + "entry.mjs": `let r; process.on("beforeExit", () => r());\nawait new Promise(res => { r = res; });\nthrow new Error("boom");\n`, + }, + "./entry.mjs", + ); + expect(r.stderr).toContain("boom"); + expect(r.stderr).not.toContain("Detected unsettled top-level await"); + expect(r.exitCode).toBe(1); + }); + + test("uncaughtException handler runs (and its async work) for a throw after beforeExit (exit 0)", async () => { + const r = await run( + { + "entry.mjs": `process.on("uncaughtException", e => { console.log("caught", e.message); setTimeout(() => console.log("cleanup done"), 1); });\nlet r; process.on("beforeExit", () => r());\nawait new Promise(res => { r = res; });\nthrow new Error("boom");\n`, + }, + "./entry.mjs", + ); + expect(r.stdout).toBe("caught boom\ncleanup done\n"); + expect(r.exitCode).toBe(0); + }); + + test("bun -p with an unsettled top-level await exits 13 without printing a pending promise", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-p", `await new Promise(() => {})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: SPAWN_TIMEOUT, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(proc.signalCode).toBeNull(); + expect(stdout).toBe(""); + expect(stderr).toContain("Detected unsettled top-level await"); + expect(exitCode).toBe(13); + }); + + test("bun -e with an unsettled top-level await exits 13", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `await new Promise(() => {})`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: SPAWN_TIMEOUT, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(proc.signalCode).toBeNull(); + expect(stderr).toContain("Detected unsettled top-level await"); + expect(exitCode).toBe(13); + }); +});