Skip to content
16 changes: 15 additions & 1 deletion src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2445,7 +2445,21 @@ impl VirtualMachine {
return Ok(promise);
}
self.event_loop_mut().perform_gc();
self.wait_for_promise(jsc::AnyPromise::Internal(promise));
// Drain-aware wait (same shape as the worker path's
// `wait_for_promise_with_termination`): return with the promise
// still Pending once nothing in the loop can settle it, so the
// caller can report an unsettled top-level await (warn + exit 13)
// instead of spinning a no-op `tick_without_idle()` forever.
Comment thread
robobun marked this conversation as resolved.
Outdated
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
17 changes: 17 additions & 0 deletions src/runtime/cli/run_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1619,6 +1619,23 @@
}

vm.on_before_exit();

// Unsettled top-level await: the loop (and beforeExit) drained but
// the entry module's evaluation promise is still pending. Node
// prints a warning and exits 13 unless the user set an exit code.
Comment thread
robobun marked this conversation as resolved.
Outdated
if let Some(p) = vm.pending_internal_promise {
// SAFETY: `p` is a live JSC heap cell tracked by the VM.
if unsafe { &*p }.status() == PromiseStatus::Pending {
Comment thread
robobun marked this conversation as resolved.
Outdated
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;
}
}
}

Check failure on line 1638 in src/runtime/cli/run_command.rs

View check run for this annotation

Claude / Claude Code Review

New post-drain check handles only Pending; entry promise Rejected during on_before_exit is dropped

The new post-`on_before_exit()` check handles only `Pending`; if the entry promise transitions Pending→Rejected inside `on_before_exit()` (e.g. a `beforeExit` listener rejects the awaited promise), `Rejected` falls through here with no action and the process exits 0 with the error swallowed — `JSInternalPromise` rejections don't reach `handle_rejected_promises()`. The worker path this mirrors does the full tri-state check in one place (web_worker.rs:1112-1135); add a `Rejected` arm here that rou
Comment thread
robobun marked this conversation as resolved.
Outdated
Comment thread
robobun marked this conversation as resolved.
Outdated
}

if log_has_msgs(vm) {
Expand Down
92 changes: 92 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,92 @@
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({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 13 });
expect(stderr).toContain("Detected unsettled top-level await at");
expect(stderr).toContain("a.mjs");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});

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({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 13 });
expect(stderr).toContain("Detected unsettled top-level await");
});
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({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 7 });
expect(stderr).toContain("Detected unsettled top-level await");
});

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));
expect({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 13 });
const before = stderr.indexOf("beforeExit 0");
const warn = stderr.indexOf("Detected unsettled top-level await");
expect(before).toBeGreaterThanOrEqual(0);
expect(warn).toBeGreaterThan(before);
});

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({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 13 });
expect(stderr).toContain("Detected unsettled top-level await");
});
Loading