diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 191c40190c98..0c02147b0a2d 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -2445,7 +2445,17 @@ impl VirtualMachine { return Ok(promise); } self.event_loop_mut().perform_gc(); - self.wait_for_promise(jsc::AnyPromise::Internal(promise)); + // Returns (promise still Pending) once the loop has nothing to settle it. + while crate::JSPromise::status_ptr(promise) == crate::js_promise::Status::Pending { + self.event_loop_mut().tick(); + if crate::JSPromise::status_ptr(promise) != crate::js_promise::Status::Pending { + break; + } + if !self.is_event_loop_alive() { + break; + } + self.auto_tick(); + } } Ok(self.pending_internal_promise.unwrap_or(promise)) diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 8c28e83ecdea..fca545a962ad 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -1493,8 +1493,11 @@ impl Run { } } + let _entry_promise_protected; match vm.load_entry_point(entry) { Ok(promise) => { + // Root it (the stored raw ptr is not GC-visited on this path). + _entry_promise_protected = JSValue::from_cell(promise).protected(); // SAFETY: `promise` is a live GC cell returned by the module loader. let promise = unsafe { &mut *promise }; if promise.status() == PromiseStatus::Rejected { @@ -1619,6 +1622,41 @@ impl Run { } vm.on_before_exit(); + + if vm.unhandled_error_counter == 0 + && let Some(p) = vm.pending_internal_promise + { + // SAFETY: `p` is a live JSC heap cell rooted by + // `_entry_promise_protected` above. + let p = unsafe { &mut *p }; + match p.status() { + PromiseStatus::Pending => { + pretty_errorln!( + "Warning: Detected unsettled top-level await at {}", + bstr::BStr::new(vm.main()), + ); + Output::flush(); + if vm.exit_handler.exit_code == 0 { + vm.exit_handler.exit_code = 13; + } + } + PromiseStatus::Rejected + if vm.pending_internal_promise_reported_at != vm.hot_reload_counter => + { + vm.pending_internal_promise_reported_at = vm.hot_reload_counter; + // SAFETY: `vm.jsc_vm` set in `init`; FFI takes `*mut`. + let result = p.result(unsafe { &mut *vm.jsc_vm }); + let global = vm.global; + // SAFETY: `global` valid for VM lifetime. + let handled = vm.uncaught_exception(unsafe { &*global }, result, true); + p.set_handled(); + if !handled { + exit_with_unhandled_note(vm); + } + } + _ => {} + } + } } if log_has_msgs(vm) { diff --git a/test/js/bun/spawn/spawn.test.ts b/test/js/bun/spawn/spawn.test.ts index 072389decb2f..f06c104c00b4 100644 --- a/test/js/bun/spawn/spawn.test.ts +++ b/test/js/bun/spawn/spawn.test.ts @@ -802,10 +802,11 @@ describe("should not hang", () => { } }); -describe("unref() + .exited with nothing else ref'd (Windows)", () => { - // Windows: with only an unref'd uv_process_t left, uv_run() used to skip its - // body and never dequeue the IOCP exit packet, so these children busy-spun - // forever. us_loop_pump now forces one non-blocking iteration (POSIX parity). +describe("top-level await on an unref'd subprocess exits 13 (Node parity)", () => { + // These used to only resolve because the entry loader busy-spun the uws loop. + // Node exits 13 (unsettled TLA) in all three shapes; Bun now does too. The + // original Windows IOCP-unref'd-handle concern (#34478) is still covered by + // the "should not hang" block above (non-TLA context). for (const [name, body] of [ ["unref() then await .exited", `const p = Bun.spawn(opts); p.unref(); await p.exited;`], [".exited then unref() then await", `const p = Bun.spawn(opts); const done = p.exited; p.unref(); await done;`], @@ -827,12 +828,13 @@ describe("unref() + .exited with nothing else ref'd (Windows)", () => { env: bunEnv, stdout: "pipe", stderr: "pipe", + timeout: 5_000, }); const [stdout, stderr, exitCode] = await Promise.all([child.stdout.text(), child.stderr.text(), child.exited]); - expect({ stdout, stderr, exitCode, signalCode: child.signalCode }).toEqual({ - stdout: "resolved\n", - stderr: "", - exitCode: 0, + expect(stderr).toContain("Detected unsettled top-level await"); + expect({ stdout, exitCode, signalCode: child.signalCode }).toEqual({ + stdout: "", + exitCode: 13, signalCode: null, }); }); 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..0ff9e9a4fd2c --- /dev/null +++ b/test/js/node/process/unsettled-top-level-await.test.ts @@ -0,0 +1,115 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// When the entry module's top-level await never settles and nothing else refs +// the event loop, Bun used to spin `wait_for_promise` forever (100% CPU, no +// epoll park). Node prints a warning and exits 13. Issue #33283. + +// Watchdog below the per-test timeout so a regression surfaces as a clean +// SIGTERM assertion instead of a suite-level timeout. The fixed path exits in +// well under a second. +const watchdog = 3_000; + +async function run(cmd: string[], cwd?: string) { + await using proc = Bun.spawn({ + cmd, + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + timeout: watchdog, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode, signalCode: proc.signalCode }; +} + +test.concurrent("never-settling top-level await exits 13 with a warning", async () => { + using dir = tempDir("tla-unsettled", { + "a.mjs": `await new Promise(() => {});\n`, + }); + const { stderr, exitCode, signalCode } = await run([bunExe(), "a.mjs"], String(dir)); + expect(stderr).toContain("Detected unsettled top-level await at"); + expect(stderr).toContain("a.mjs"); + expect({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 13 }); +}); + +test.concurrent("never-settling top-level await in a dependency exits 13", async () => { + using dir = tempDir("tla-unsettled-dep", { + "entry.mjs": `import { ready } from "./dep.mjs";\nawait ready;\n`, + "dep.mjs": `export const ready = new Promise(() => {});\n`, + }); + const { stderr, exitCode, signalCode } = await run([bunExe(), "entry.mjs"], String(dir)); + expect(stderr).toContain("Detected unsettled top-level await"); + expect(stderr).toContain("entry.mjs"); + expect({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 13 }); +}); + +test.concurrent("top-level await resolved by a ref'd timer exits 0", async () => { + using dir = tempDir("tla-timer", { + "a.mjs": `await new Promise(r => setTimeout(r, 50));\nconsole.log("done");\n`, + }); + const { stdout, stderr, exitCode, signalCode } = await run([bunExe(), "a.mjs"], String(dir)); + expect(stdout).toBe("done\n"); + expect(stderr).not.toContain("Detected unsettled top-level await"); + expect({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 0 }); +}); + +test.concurrent("top-level await resolved by a microtask exits 0", async () => { + using dir = tempDir("tla-micro", { + "a.mjs": `await new Promise(r => queueMicrotask(r));\nconsole.log("done");\n`, + }); + const { stdout, stderr, exitCode, signalCode } = await run([bunExe(), "a.mjs"], String(dir)); + expect(stdout).toBe("done\n"); + expect(stderr).not.toContain("Detected unsettled top-level await"); + expect({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 0 }); +}); + +test.concurrent("unsettled top-level await preserves a user-set process.exitCode", async () => { + using dir = tempDir("tla-exitcode", { + "a.mjs": `process.exitCode = 7;\nawait new Promise(() => {});\n`, + }); + const { stderr, exitCode, signalCode } = await run([bunExe(), "a.mjs"], String(dir)); + expect(stderr).toContain("Detected unsettled top-level await"); + expect({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 7 }); +}); + +test.concurrent("beforeExit fires with 0 before the unsettled-TLA warning", async () => { + using dir = tempDir("tla-beforeexit", { + "a.mjs": `process.on("beforeExit", c => console.error("beforeExit", c));\n` + `await new Promise(() => {});\n`, + }); + const { stderr, exitCode, signalCode } = await run([bunExe(), "a.mjs"], String(dir)); + const before = stderr.indexOf("beforeExit 0"); + const warn = stderr.indexOf("Detected unsettled top-level await"); + expect(before).toBeGreaterThanOrEqual(0); + expect(warn).toBeGreaterThan(before); + expect({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 13 }); +}); + +test.concurrent("bun -e with a never-settling top-level await exits 13", async () => { + const { stderr, exitCode, signalCode } = await run([bunExe(), "-e", "await new Promise(() => {})"]); + expect(stderr).toContain("Detected unsettled top-level await"); + expect({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 13 }); +}); + +test.concurrent("uncaught exception during top-level await does not print a spurious TLA warning", async () => { + using dir = tempDir("tla-uncaught", { + "a.mjs": `setImmediate(() => { throw new Error("boom"); });\nawait new Promise(r => setTimeout(r, 100));\n`, + }); + const { stderr, exitCode, signalCode } = await run([bunExe(), "a.mjs"], String(dir)); + expect(stderr).toContain("boom"); + expect(stderr).not.toContain("Detected unsettled top-level await"); + expect({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 1 }); +}); + +test.concurrent("top-level await rejected during beforeExit is reported (exit 1, not swallowed)", async () => { + using dir = tempDir("tla-reject-beforeexit", { + "a.mjs": + `const { promise, reject } = Promise.withResolvers();\n` + + `process.once("beforeExit", () => setImmediate(() => reject(new Error("db never connected"))));\n` + + `await promise;\n`, + }); + const { stderr, exitCode, signalCode } = await run([bunExe(), "a.mjs"], String(dir)); + expect(stderr).toContain("db never connected"); + expect(stderr).not.toContain("Detected unsettled top-level await"); + expect({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 1 }); +});