From 049909efc36b5aceee7a8fa6237f13675e37aa93 Mon Sep 17 00:00:00 2001 From: robobun Date: Tue, 21 Apr 2026 13:37:19 +0000 Subject: [PATCH 01/33] Exit unsettled top-level await instead of hanging / busy-looping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unref'd Bun timer (e.g. AbortSignal.timeout()) awaited at the top level with no ref'd handle keeping the loop alive hit two distinct event-loop bugs: POSIX: autoTick() took the tickWithoutIdle() branch (zero-timeout poll) and drainTimers() fired the timer eventually, but only after a tight busy loop burning ~100% CPU for the full wait. Windows: tickWithoutIdle -> us_loop_pump -> uv_run(UV_RUN_NOWAIT). uv_run early-returns when uv__loop_alive() is false, so the unref'd uv timer that would drain Bun's heap never fired. The process hung forever. Add EventLoop.waitForPromiseOrLoopExit: like waitForPromise, but bails when isEventLoopAlive() goes false. Route the three TLA entry paths (loadEntryPoint, loadEntryPointForTestRunner, loadPreloads) plus the watcher variants through it, and give waitForPromiseWithTermination the same break so workers with unsettled TLA exit cleanly too. waitForPromise itself is unchanged — its contract ('returns with the promise resolved') is load-bearing for callers like Expect's toThrow. Matches Node.js, which also exits an unsettled top-level await without waiting on unref'd handles. Fixes #29546 --- src/jsc/VirtualMachine.zig | 26 ++++-- src/jsc/event_loop.zig | 47 ++++++++++ test/regression/issue/29546.test.ts | 127 ++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 test/regression/issue/29546.test.ts diff --git a/src/jsc/VirtualMachine.zig b/src/jsc/VirtualMachine.zig index 461906c6b3a..ba807850836 100644 --- a/src/jsc/VirtualMachine.zig +++ b/src/jsc/VirtualMachine.zig @@ -2256,15 +2256,18 @@ fn loadPreloads(this: *VirtualMachine) !?*JSInternalPromise { if (this.pending_internal_promise.?.status() == .pending) { this.eventLoop().autoTick(); } + + // See loadEntryPoint — same unsettled-TLA escape hatch. + if (this.pending_internal_promise.?.status() == .pending and !this.isEventLoopAlive()) { + break; + } } }, else => {}, } } else { this.eventLoop().performGC(); - this.waitForPromise(jsc.AnyPromise{ - .internal = promise, - }); + this.eventLoop().waitForPromiseOrLoopExit(.{ .internal = promise }); } if (promise.status() == .rejected) @@ -2435,6 +2438,11 @@ pub fn loadEntryPointForTestRunner(this: *VirtualMachine, entry_path: string) an if (this.pending_internal_promise.?.status() == .pending) { this.eventLoop().autoTick(); } + + // See loadEntryPoint — same unsettled-TLA escape hatch. + if (this.pending_internal_promise.?.status() == .pending and !this.isEventLoopAlive()) { + break; + } } }, else => {}, @@ -2445,7 +2453,7 @@ pub fn loadEntryPointForTestRunner(this: *VirtualMachine, entry_path: string) an } this.eventLoop().performGC(); - this.waitForPromise(.{ .internal = promise }); + this.eventLoop().waitForPromiseOrLoopExit(.{ .internal = promise }); } this.eventLoop().autoTick(); @@ -2467,6 +2475,14 @@ pub fn loadEntryPoint(this: *VirtualMachine, entry_path: string) anyerror!*JSInt if (this.pending_internal_promise.?.status() == .pending) { this.eventLoop().autoTick(); } + + // Top-level await with no ref'd handle to resolve it: + // bail so POSIX doesn't burn 100% CPU and Windows doesn't + // hang on `uv_run(NOWAIT)` skipping its loop body. See + // EventLoop.waitForPromiseOrLoopExit for details. + if (this.pending_internal_promise.?.status() == .pending and !this.isEventLoopAlive()) { + break; + } } }, else => {}, @@ -2477,7 +2493,7 @@ pub fn loadEntryPoint(this: *VirtualMachine, entry_path: string) anyerror!*JSInt } this.eventLoop().performGC(); - this.waitForPromise(.{ .internal = promise }); + this.eventLoop().waitForPromiseOrLoopExit(.{ .internal = promise }); } return this.pending_internal_promise.?; diff --git a/src/jsc/event_loop.zig b/src/jsc/event_loop.zig index f43114231e5..c1f2c6448e7 100644 --- a/src/jsc/event_loop.zig +++ b/src/jsc/event_loop.zig @@ -575,6 +575,44 @@ pub fn waitForPromise(this: *EventLoop, promise: jsc.AnyPromise) void { } } +/// Like `waitForPromise`, but returns early when the event loop has nothing +/// left that could resolve the promise — no active uv/uws handles, no tasks, +/// no concurrent refs, no immediates. Used by the top-level-await entry +/// points where the promise may be "unsettled" (e.g. awaiting an abort event +/// whose only source is an unref'd `AbortSignal.timeout()` timer). +/// +/// Without this, POSIX busy-loops at 100% CPU until the unref'd timer fires +/// and Windows hangs forever (`uv_run(UV_RUN_NOWAIT)` early-returns when +/// `uv__loop_alive()` is false, so unref'd Bun timers never fire via the uv +/// scheduler). Matches Node.js, which also exits an unsettled top-level +/// await without waiting on unref'd handles. +/// +/// Callers that require a resolved promise on return should keep using +/// `waitForPromise` — this variant is specifically for the top-level-entry +/// path, which is prepared to observe a still-pending promise. +pub fn waitForPromiseOrLoopExit(this: *EventLoop, promise: jsc.AnyPromise) void { + const jsc_vm = this.virtual_machine.jsc_vm; + switch (promise.status()) { + .pending => { + while (promise.status() == .pending) { + if (jsc_vm.executionForbidden()) { + break; + } + this.tick(); + + if (promise.status() == .pending) { + this.autoTick(); + } + + if (promise.status() == .pending and !this.virtual_machine.isEventLoopAlive()) { + break; + } + } + }, + else => {}, + } +} + pub fn waitForPromiseWithTermination(this: *EventLoop, promise: jsc.AnyPromise) void { const worker = this.virtual_machine.worker orelse @panic("EventLoop.waitForPromiseWithTermination: worker is not initialized"); switch (promise.status()) { @@ -585,6 +623,15 @@ pub fn waitForPromiseWithTermination(this: *EventLoop, promise: jsc.AnyPromise) if (!worker.hasRequestedTerminate() and promise.status() == .pending) { this.autoTick(); } + + // Same unsettled-TLA escape hatch as waitForPromiseOrLoopExit. + // Without this, a worker whose entry point has an unsettled + // top-level await (e.g. `await new Promise(() => {})`) would + // busy-loop forever on POSIX / hang on Windows instead of + // reaching the normal "worker done" path. + if (promise.status() == .pending and !this.virtual_machine.isEventLoopAlive()) { + break; + } } }, else => {}, diff --git a/test/regression/issue/29546.test.ts b/test/regression/issue/29546.test.ts new file mode 100644 index 00000000000..2392f8b8b3d --- /dev/null +++ b/test/regression/issue/29546.test.ts @@ -0,0 +1,127 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// https://github.com/oven-sh/bun/issues/29546 +// +// `AbortSignal.timeout(N)` schedules an unref'd timer in Bun's own timer +// heap. When awaited at the top level — with no ref'd handles keeping the +// loop alive — the event loop had two bugs: +// +// POSIX: `autoTick()` took the `tickWithoutIdle()` branch (zero-timeout +// poll) and then `drainTimers()`. The timer eventually fired, but +// only after a tight busy loop (~100% CPU for the full wait). +// +// Windows: `tickWithoutIdle` -> `us_loop_pump` -> `uv_run(NOWAIT)`. That +// call skips its loop body when `uv__loop_alive()` is false (no +// ref'd handles), so the uv timer that would have drained Bun's +// heap never fired. The process hung forever. +// +// `waitForPromise` now breaks when the event loop has nothing left to make +// progress — no active handles, no tasks, no concurrent refs, no immediates +// — matching Node.js (unsettled top-level await exits cleanly rather than +// waiting on unref'd handles). + +function stripAsanWarning(stderr: string): string { + return stderr + .split("\n") + .filter(l => !l.startsWith("WARNING: ASAN interferes")) + .join("\n"); +} + +test("AbortSignal.timeout awaited at top-level does not hang or spin", async () => { + // The timeout is deliberately long (60s). Before the fix, POSIX would + // busy-loop burning CPU for the full duration and Windows would hang + // forever. With the fix, the process exits cleanly well under 60s + // because the unref'd timer doesn't keep the loop alive. + const source = ` + async function run(signal) { + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve("run aborted")); + }); + } + + const r = await run(AbortSignal.timeout(60_000)); + console.log(r); + `; + + const started = performance.now(); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", source], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + const elapsed = performance.now() - started; + + expect(stripAsanWarning(stderr)).toBe(""); + expect(stdout).toBe(""); + expect(exitCode).toBe(0); + expect(elapsed).toBeLessThan(30_000); +}); + +test("AbortSignal.timeout fires when something else keeps the loop alive", async () => { + // Regression guard for the fix above — an unref'd AbortSignalTimeout must + // still fire when a ref'd handle keeps the loop open, so the abort + // listener runs and resolves the awaited promise as expected. + const source = ` + async function run(signal) { + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve("run aborted")); + }); + } + + const keepAlive = setTimeout(() => {}, 60_000); + const r = await run(AbortSignal.timeout(100)); + console.log(r); + clearTimeout(keepAlive); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", source], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + expect(stripAsanWarning(stderr)).toBe(""); + expect(stdout).toBe("run aborted\n"); + expect(exitCode).toBe(0); +}); + +test("top-level await on a never-resolving promise exits cleanly", async () => { + // Same event-loop fix — a pending TLA with no ref'd handles must not spin + // (POSIX) or hang (Windows). Before the fix, this script hung forever on + // Windows and burned CPU in a tight loop on POSIX. + const started = performance.now(); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + "console.log('before'); await new Promise(() => {}); console.log('after');", + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + const elapsed = performance.now() - started; + + expect(stripAsanWarning(stderr)).toBe(""); + expect(stdout).toBe("before\n"); + expect(exitCode).toBe(0); + expect(elapsed).toBeLessThan(30_000); +}); From 5bc2b0769d60caee2da9bfc956d2a99d808ef8be Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:39:37 +0000 Subject: [PATCH 02/33] [autofix.ci] apply automated fixes --- test/regression/issue/29546.test.ts | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/test/regression/issue/29546.test.ts b/test/regression/issue/29546.test.ts index 2392f8b8b3d..e7c8caa0c88 100644 --- a/test/regression/issue/29546.test.ts +++ b/test/regression/issue/29546.test.ts @@ -51,11 +51,7 @@ test("AbortSignal.timeout awaited at top-level does not hang or spin", async () stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); const elapsed = performance.now() - started; expect(stripAsanWarning(stderr)).toBe(""); @@ -87,11 +83,7 @@ test("AbortSignal.timeout fires when something else keeps the loop alive", async stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stripAsanWarning(stderr)).toBe(""); expect(stdout).toBe("run aborted\n"); @@ -104,20 +96,12 @@ test("top-level await on a never-resolving promise exits cleanly", async () => { // Windows and burned CPU in a tight loop on POSIX. const started = performance.now(); await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - "console.log('before'); await new Promise(() => {}); console.log('after');", - ], + cmd: [bunExe(), "-e", "console.log('before'); await new Promise(() => {}); console.log('after');"], env: bunEnv, stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); const elapsed = performance.now() - started; expect(stripAsanWarning(stderr)).toBe(""); From 06cdf7625fa3186b50dde4eaac4e74c0b844fa84 Mon Sep 17 00:00:00 2001 From: robobun Date: Tue, 21 Apr 2026 14:24:47 +0000 Subject: [PATCH 03/33] Narrow the TLA loop-exit check: handle-work, not isEventLoopAlive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two review findings: 1. (coderabbit, major) loadPreloads + loadEntryPointForTestRunner could now return with pending_internal_promise, silently advancing to the next preload / reporting 0 tests instead of waiting. Revert those paths — preloads/testrunner keep the old 'wait until resolved' contract. Only loadEntryPoint (the 'bun run