Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
67719b7698 fix: detect unsettled top-level await and exit instead of busy-waiting
When a top-level await waits on a promise that can never resolve (e.g.
`await new Promise(() => {})`), the event loop would spin at 100% CPU.
This happened because `waitForPromise` called `autoTick()` which, when
no handles were active, called `tickWithoutIdle()` with a zero timeout,
causing epoll_wait to return immediately in a tight loop.

Now, after each tick in `waitForPromise`, we check if the event loop has
nothing active (no handles, no tasks, no concurrent refs). If so, the
promise can never settle, so we break out of the loop. The caller then
detects the still-pending promise and exits with code 13 and a warning,
matching Node.js behavior for unsettled top-level await.

Closes #14951

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 02:31:49 +00:00
4 changed files with 75 additions and 0 deletions

View File

@@ -388,6 +388,20 @@ pub const Run = struct {
}
}
if (promise.status() == .pending) {
// The promise is still pending after waitForPromise returned, which means
// nothing in the event loop can resolve it (unsettled top-level await).
// Exit with code 13, matching Node.js behavior for unsettled TLA.
vm.exit_handler.exit_code = 13;
Output.prettyErrorln(
"<r><yellow>warning<r>: Detected unsettled top-level await at {s}",
.{this.entry_path},
);
Output.flush();
vm.onExit();
vm.globalExit();
}
_ = promise.result();
if (vm.log.msgs.items.len > 0) {

View File

@@ -2102,6 +2102,10 @@ fn loadPreloads(this: *VirtualMachine) !?*JSInternalPromise {
if (this.pending_internal_promise.?.status() == .pending) {
this.eventLoop().autoTick();
}
if (this.pending_internal_promise.?.status() == .pending and !this.isEventLoopAlive()) {
break;
}
}
},
else => {},
@@ -2264,6 +2268,10 @@ pub fn loadEntryPointForTestRunner(this: *VirtualMachine, entry_path: string) an
if (this.pending_internal_promise.?.status() == .pending) {
this.eventLoop().autoTick();
}
if (this.pending_internal_promise.?.status() == .pending and !this.isEventLoopAlive()) {
break;
}
}
},
else => {},
@@ -2296,6 +2304,10 @@ pub fn loadEntryPoint(this: *VirtualMachine, entry_path: string) anyerror!*JSInt
if (this.pending_internal_promise.?.status() == .pending) {
this.eventLoop().autoTick();
}
if (this.pending_internal_promise.?.status() == .pending and !this.isEventLoopAlive()) {
break;
}
}
},
else => {},

View File

@@ -540,6 +540,14 @@ pub fn waitForPromise(this: *EventLoop, promise: jsc.AnyPromise) void {
if (promise.status() == .pending) {
this.autoTick();
}
// If the promise is still pending but nothing in the event loop can
// make progress (no active handles, no pending tasks, no concurrent refs),
// the promise can never settle. Break to avoid busy-waiting at 100% CPU.
// This handles unsettled top-level await (e.g. `await new Promise(() => {})`).
if (promise.status() == .pending and !this.virtual_machine.isEventLoopAlive()) {
break;
}
}
},
else => {},
@@ -556,6 +564,12 @@ pub fn waitForPromiseWithTermination(this: *EventLoop, promise: jsc.AnyPromise)
if (!worker.hasRequestedTerminate() and promise.status() == .pending) {
this.autoTick();
}
// If the promise is still pending but nothing in the event loop can
// make progress, the promise can never settle. Break to avoid busy-waiting.
if (promise.status() == .pending and !this.virtual_machine.isEventLoopAlive()) {
break;
}
}
},
else => {},

View File

@@ -0,0 +1,35 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
test("top-level await on never-resolving promise should not cause 100% CPU usage", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", "await new Promise(r => {})"],
env: bunEnv,
stderr: "pipe",
});
// The process should exit on its own (not hang). Give it a generous timeout.
const timeout = setTimeout(() => {
proc.kill();
}, 10_000);
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
clearTimeout(timeout);
expect(stderr).toContain("unsettled top-level await");
expect(exitCode).toBe(13);
});
test("top-level await on resolving promise should work normally", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", "await new Promise(r => setTimeout(r, 100)); console.log('done')"],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("done");
expect(exitCode).toBe(0);
});