Skip to content
12 changes: 11 additions & 1 deletion src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
}

Ok(self.pending_internal_promise.unwrap_or(promise))
Expand Down
40 changes: 40 additions & 0 deletions src/runtime/cli/run_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1493,8 +1493,13 @@ impl Run {
}
}

let _entry_promise_protected;
match vm.load_entry_point(entry) {
Ok(promise) => {
// `pending_internal_promise` is a raw, unvisited pointer on this
// path; root it so the post-`on_before_exit` status read below
// survives the intervening GC and user JS.
Comment thread
robobun marked this conversation as resolved.
Outdated
_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 {
Expand Down Expand Up @@ -1619,6 +1624,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!(
"<r><yellow>Warning<r><d>:<r> 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) {
Expand Down
18 changes: 10 additions & 8 deletions test/js/bun/spawn/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;`],
Expand All @@ -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,
});
});
Expand Down
115 changes: 115 additions & 0 deletions test/js/node/process/unsettled-top-level-await.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 });
});
Loading