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 @@
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();
}

Check failure on line 2458 in src/jsc/VirtualMachine.rs

View check run for this annotation

Claude / Claude Code Review

Regression: unref'd handle + top-level await now exits 13, breaking spawn.test.ts on all CI platforms

The new `!is_event_loop_alive()` break regresses `p.unref(); await p.exited` — an unref'd subprocess doesn't count toward liveness, so the loop bails before `auto_tick()` ever pumps the OS loop, the entry await stays Pending, and the process exits 13 with the warning instead of resolving. This is failing `test/js/bun/spawn/spawn.test.ts:805-840` ("unref() + .exited with nothing else ref'd") on **all 8 CI platforms** per the robobun comment. Either narrow the check so an in-flight unref'd child s

Check warning on line 2458 in src/jsc/VirtualMachine.rs

View check run for this annotation

Claude / Claude Code Review

Sibling wait_for_promise sites (test runner, --preload) still spin on unsettled TLA — guard not moved into shared helper

Two byte-identical sibling sites still call the unfixed `wait_for_promise` and will spin at 100% CPU on a never-settling top-level await: `load_entry_point_for_test_runner` (VirtualMachine.rs:4604 — `bun test foo.test.ts` with module-scope `await new Promise(()=>{})`) and the `--preload` wait (jsc_hooks.rs:836 — `bun --preload hang.mjs entry.js`). Per REVIEW.md's "fix the whole class… prefer moving the guard into the shared helper; if a site is intentionally excluded, say so in the PR": consider
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
14 changes: 14 additions & 0 deletions src/runtime/cli/run_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1619,6 +1619,20 @@
}

vm.on_before_exit();

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 {

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

View check run for this annotation

Claude / Claude Code Review

pending_internal_promise dereferenced after GC-triggering code without a GC root; SAFETY comment is false

The `SAFETY: p is a live JSC heap cell tracked by the VM` comment is false — in the non-watcher path `pending_internal_promise` is stored with `is_protected = false` and only `ensure_still_alive()` (frame-local `black_box`), and no C++ `visitChildren` visits it. This deref runs after the stack local dies at line 1530, after `run_gc(false)` at 1540, the drain loop, and `on_before_exit()`'s inner tick loop; once the entry promise settles it is reachable only from this unrooted raw pointer, so GC c
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 1635 in src/runtime/cli/run_command.rs

View check run for this annotation

Claude / Claude Code Review

Spurious 'unsettled top-level await' warning when wait was aborted by a fatal error, not a natural drain

The new Pending check fires whenever `is_event_loop_alive()` returned false, but that function also returns false when `unhandled_error_counter > 0` (VirtualMachine.rs:1036) — i.e. the wait was aborted by a fatal uncaught exception, not a natural drain. In that case ref'd handles that would have settled the entry promise are still live, so the "Detected unsettled top-level await" warning is spurious and misattributes the failure (Node prints only the exception). Gate this block on `vm.unhandled_
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(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 });
});
Loading