From 90b32018b67a2a825e8b1369d2ac4a892473d00b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:54:11 +0000 Subject: [PATCH 1/6] bun:test: bound expect() promise waits by the test timeout expect(promise).resolves/.rejects, expect(fn).toThrow on an async fn, and async custom matchers spin the event loop via wait_for_promise until the subject promise settles. When the promise can only settle after the matcher returns (e.g. expect(p).resolves.toBe(x); resolve(x);) the spin never ends. The per-test timeout timer fires inside that spin (via auto_tick -> drain_timers -> bun_test_timeout_callback), but since e01f4546 it no longer calls requestTermination(), so nothing breaks the loop. The runner records the timeout, returns immediately because it is re-entrant, and the spin continues at 100% CPU forever. Replace the three expect.rs wait_for_promise calls with a bounded loop that also breaks once the enclosing test's deadline (read via TestRunner::get_active_timeout(), the same source spawnSync already uses) passes, and throw a matcher error naming the still-pending promise instead of hitting unreachable!(). Outside a timed test the deadline is EPOCH and the loop behaves exactly like wait_for_promise. Fixes #14950 --- src/runtime/test_runner/expect.rs | 65 ++++++++++++- test/regression/issue/14950.test.ts | 145 ++++++++++++++++++++++++++++ test/regression/issue/23865.test.ts | 9 ++ 3 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 test/regression/issue/14950.test.ts diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index dabe89cb2812..f0ca452133be 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -471,6 +471,43 @@ impl Expect { } } + /// Spin the event loop until `promise` settles, like `wait_for_promise`, + /// but stop once the enclosing test's timeout deadline passes so a matcher + /// awaiting a promise that can only settle *after* the matcher returns + /// (e.g. `expect(p).resolves.toBe(x); resolve(x);`) turns into a test + /// timeout instead of a 100% CPU hang. Returns `true` if the promise is + /// still pending when the spin ends. + fn wait_for_promise_bounded_by_test( + global_this: &JSGlobalObject, + promise: bun_jsc::AnyPromise, + ) -> bool { + use bun_core::{Timespec, TimespecMockMode}; + if promise.status() != js_promise::Status::Pending { + return false; + } + // `on_stack_entry` is set for the duration of `run_test_callback`, so the + // deadline is stable across the spin; read it once. EPOCH = no timeout. + let deadline = Jest::runner().map_or(Timespec::EPOCH, |r| r.get_active_timeout()); + let jsc_vm = global_this.vm(); + let bun_vm = global_this.bun_vm().as_mut(); + while promise.status() == js_promise::Status::Pending { + if jsc_vm.execution_forbidden() { + break; + } + if !deadline.eql(&Timespec::EPOCH) + && deadline.order(&Timespec::now(TimespecMockMode::ForceRealTime)) + == core::cmp::Ordering::Less + { + break; + } + bun_vm.event_loop_mut().tick(); + if promise.status() == js_promise::Status::Pending { + bun_vm.event_loop_mut().auto_tick(); + } + } + promise.status() == js_promise::Status::Pending + } + /// Processes the async flags (resolves/rejects), waiting for the async value if needed. /// If no flags, returns the original value /// If either flag is set, waits for the result, and returns either it as a JSValue, or null if the expectation failed (in which case if silent is false, also throws a js exception) @@ -489,8 +526,17 @@ impl Expect { let vm = global_this.vm(); promise.set_handled(vm); - // SAFETY: bun_vm() returns the live thread-local VirtualMachine. - global_this.bun_vm().as_mut().wait_for_promise(promise); + if Self::wait_for_promise_bounded_by_test(global_this, promise) { + if !silent { + return Err(Self::throw_promise_matcher_error( + global_this, custom_label, matcher_name, matcher_params, flags, + "Expected promise to settle within the test timeout", + "Received promise that is still pending: ", + "[Promise]", + )); + } + return Err(JsError::Thrown); + } let new_value = promise.result(vm); match promise.status() { @@ -893,8 +939,13 @@ impl Expect { } if let Some(promise) = return_value.as_any_promise() { - vm.wait_for_promise(promise); + let still_pending = Self::wait_for_promise_bounded_by_test(global_this, promise); scope.apply(vm); + if still_pending { + return Err(global_this.throw(format_args!( + "Received function returned a promise that did not settle within the test timeout", + ))); + } match promise.unwrap(global_this.vm(), js_promise::UnwrapMode::MarkHandled) { js_promise::Unwrapped::Fulfilled(_) => { return Ok((None, return_value_from_function)); @@ -1487,8 +1538,12 @@ impl Expect { let vm = global_this.vm(); promise.set_handled(vm); - // SAFETY: bun_vm() returns the live thread-local VirtualMachine. - global_this.bun_vm().as_mut().wait_for_promise(promise); + if Self::wait_for_promise_bounded_by_test(global_this, promise) { + return Err(global_this.throw(format_args!( + "Matcher `{}` returned a promise that did not settle within the test timeout", + matcher_name, + ))); + } result = promise.result(vm); result.ensure_still_alive(); diff --git a/test/regression/issue/14950.test.ts b/test/regression/issue/14950.test.ts new file mode 100644 index 000000000000..ce5e14c6e777 --- /dev/null +++ b/test/regression/issue/14950.test.ts @@ -0,0 +1,145 @@ +// https://github.com/oven-sh/bun/issues/14950 +// `expect(pendingPromise).resolves.()` in a sync test body must not +// hang `bun test` forever at 100% CPU; the per-test timeout has to fire. +import { test, expect, describe } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +async function runTestFile(name: string, body: string) { + using dir = tempDir(name, { "t.test.js": body }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "t.test.js", "--timeout", "500"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + // On an unfixed build the inner runner never exits (the per-test --timeout + // cannot interrupt the wait_for_promise spin), so kill it ourselves instead + // of letting the outer runner's own timeout abort the assertion. + let hung = false; + const watchdog = setTimeout(() => { + hung = true; + proc.kill(); + }, 20_000); + try { + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + return { stdout, stderr, exitCode, hung }; + } finally { + clearTimeout(watchdog); + } +} + +describe.concurrent("expect().resolves/.rejects on a not-yet-settled promise", () => { + test( + ".resolves on a promise resolved after the matcher call times out instead of hanging", + async () => { + const { stderr, exitCode, hung } = await runTestFile( + "issue-14950-resolves", + `test("promise resolves after expect call", () => { + let resolve; + expect(new Promise(r => (resolve = r))).resolves.toBe(25); + resolve(25); + });`, + ); + expect(hung).toBe(false); + expect(stderr).toContain("still pending"); + expect(stderr).toMatch(/timed out after \d+ms/); + expect(exitCode).toBe(1); + }, + 60_000, + ); + + test( + ".rejects on a never-settling promise times out instead of hanging", + async () => { + const { stderr, exitCode, hung } = await runTestFile( + "issue-14950-rejects", + `test("never settles", () => { + expect(new Promise(() => {})).rejects.toThrow(); + });`, + ); + expect(hung).toBe(false); + expect(stderr).toContain("still pending"); + expect(stderr).toMatch(/timed out after \d+ms/); + expect(exitCode).toBe(1); + }, + 60_000, + ); + + test( + "toThrow on an async fn returning a never-settling promise times out instead of hanging", + async () => { + const { stderr, exitCode, hung } = await runTestFile( + "issue-14950-tothrow", + `test("never settles", () => { + expect(() => new Promise(() => {})).toThrow(); + });`, + ); + expect(hung).toBe(false); + expect(stderr).toContain("did not settle within the test timeout"); + expect(exitCode).toBe(1); + }, + 60_000, + ); + + test( + "async custom matcher returning a never-settling promise times out instead of hanging", + async () => { + const { stderr, exitCode, hung } = await runTestFile( + "issue-14950-custom", + `const { expect, test } = require("bun:test"); + expect.extend({ + toNeverSettle() { return new Promise(() => {}); }, + }); + test("never settles", () => { + expect(1).toNeverSettle(); + });`, + ); + expect(hung).toBe(false); + expect(stderr).toContain("did not settle within the test timeout"); + expect(exitCode).toBe(1); + }, + 60_000, + ); + + test( + "a test after one that spins on .resolves still runs", + async () => { + const { stderr, exitCode, hung } = await runTestFile( + "issue-14950-next-test", + `test("hangs", () => { + expect(new Promise(() => {})).resolves.toBe(1); + }); + test("runs after", () => { + expect(1).toBe(1); + });`, + ); + expect(hung).toBe(false); + expect(stderr).toMatch(/1 pass/); + expect(stderr).toMatch(/1 fail/); + expect(exitCode).toBe(1); + }, + 60_000, + ); + + test( + ".resolves on an already-resolved promise still passes synchronously", + async () => { + const { stderr, exitCode, hung } = await runTestFile( + "issue-14950-settled", + `test("already settled", () => { + expect(Promise.resolve(25)).resolves.toBe(25); + });`, + ); + expect(hung).toBe(false); + expect(stderr).toMatch(/1 pass/); + expect(stderr).not.toContain("still pending"); + expect(exitCode).toBe(0); + }, + 60_000, + ); +}); diff --git a/test/regression/issue/23865.test.ts b/test/regression/issue/23865.test.ts index b4f5dadea87a..eb5414ae3281 100644 --- a/test/regression/issue/23865.test.ts +++ b/test/regression/issue/23865.test.ts @@ -16,6 +16,15 @@ test("23865", async () => { expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`"bun test ()"`); expect(normalizeBunSnapshot(stderr)).toMatchInlineSnapshot(` "23865.fixture.ts: + 1 | // Should not crash + 2 | test("abc", () => { + 3 | expect(async () => { + 4 | await Bun.sleep(100); + 5 | throw new Error("uh oh!"); + 6 | }).toThrow("uh oh!"); + ^ + error: Received function returned a promise that did not settle within the test timeout + at (file:NN:NN) (fail) abc ^ this test timed out after 50ms. From d44eed90920ed9e77a831746155fe20a18617cc2 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:57:25 +0000 Subject: [PATCH 2/6] [autofix.ci] apply automated fixes --- test/regression/issue/14950.test.ts | 148 +++++++++++----------------- 1 file changed, 60 insertions(+), 88 deletions(-) diff --git a/test/regression/issue/14950.test.ts b/test/regression/issue/14950.test.ts index ce5e14c6e777..663fe2913d6e 100644 --- a/test/regression/issue/14950.test.ts +++ b/test/regression/issue/14950.test.ts @@ -1,7 +1,7 @@ // https://github.com/oven-sh/bun/issues/14950 // `expect(pendingPromise).resolves.()` in a sync test body must not // hang `bun test` forever at 100% CPU; the per-test timeout has to fire. -import { test, expect, describe } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; async function runTestFile(name: string, body: string) { @@ -22,11 +22,7 @@ async function runTestFile(name: string, body: string) { proc.kill(); }, 20_000); try { - 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]); return { stdout, stderr, exitCode, hung }; } finally { clearTimeout(watchdog); @@ -34,112 +30,88 @@ async function runTestFile(name: string, body: string) { } describe.concurrent("expect().resolves/.rejects on a not-yet-settled promise", () => { - test( - ".resolves on a promise resolved after the matcher call times out instead of hanging", - async () => { - const { stderr, exitCode, hung } = await runTestFile( - "issue-14950-resolves", - `test("promise resolves after expect call", () => { + test(".resolves on a promise resolved after the matcher call times out instead of hanging", async () => { + const { stderr, exitCode, hung } = await runTestFile( + "issue-14950-resolves", + `test("promise resolves after expect call", () => { let resolve; expect(new Promise(r => (resolve = r))).resolves.toBe(25); resolve(25); });`, - ); - expect(hung).toBe(false); - expect(stderr).toContain("still pending"); - expect(stderr).toMatch(/timed out after \d+ms/); - expect(exitCode).toBe(1); - }, - 60_000, - ); + ); + expect(hung).toBe(false); + expect(stderr).toContain("still pending"); + expect(stderr).toMatch(/timed out after \d+ms/); + expect(exitCode).toBe(1); + }, 60_000); - test( - ".rejects on a never-settling promise times out instead of hanging", - async () => { - const { stderr, exitCode, hung } = await runTestFile( - "issue-14950-rejects", - `test("never settles", () => { + test(".rejects on a never-settling promise times out instead of hanging", async () => { + const { stderr, exitCode, hung } = await runTestFile( + "issue-14950-rejects", + `test("never settles", () => { expect(new Promise(() => {})).rejects.toThrow(); });`, - ); - expect(hung).toBe(false); - expect(stderr).toContain("still pending"); - expect(stderr).toMatch(/timed out after \d+ms/); - expect(exitCode).toBe(1); - }, - 60_000, - ); + ); + expect(hung).toBe(false); + expect(stderr).toContain("still pending"); + expect(stderr).toMatch(/timed out after \d+ms/); + expect(exitCode).toBe(1); + }, 60_000); - test( - "toThrow on an async fn returning a never-settling promise times out instead of hanging", - async () => { - const { stderr, exitCode, hung } = await runTestFile( - "issue-14950-tothrow", - `test("never settles", () => { + test("toThrow on an async fn returning a never-settling promise times out instead of hanging", async () => { + const { stderr, exitCode, hung } = await runTestFile( + "issue-14950-tothrow", + `test("never settles", () => { expect(() => new Promise(() => {})).toThrow(); });`, - ); - expect(hung).toBe(false); - expect(stderr).toContain("did not settle within the test timeout"); - expect(exitCode).toBe(1); - }, - 60_000, - ); + ); + expect(hung).toBe(false); + expect(stderr).toContain("did not settle within the test timeout"); + expect(exitCode).toBe(1); + }, 60_000); - test( - "async custom matcher returning a never-settling promise times out instead of hanging", - async () => { - const { stderr, exitCode, hung } = await runTestFile( - "issue-14950-custom", - `const { expect, test } = require("bun:test"); + test("async custom matcher returning a never-settling promise times out instead of hanging", async () => { + const { stderr, exitCode, hung } = await runTestFile( + "issue-14950-custom", + `const { expect, test } = require("bun:test"); expect.extend({ toNeverSettle() { return new Promise(() => {}); }, }); test("never settles", () => { expect(1).toNeverSettle(); });`, - ); - expect(hung).toBe(false); - expect(stderr).toContain("did not settle within the test timeout"); - expect(exitCode).toBe(1); - }, - 60_000, - ); + ); + expect(hung).toBe(false); + expect(stderr).toContain("did not settle within the test timeout"); + expect(exitCode).toBe(1); + }, 60_000); - test( - "a test after one that spins on .resolves still runs", - async () => { - const { stderr, exitCode, hung } = await runTestFile( - "issue-14950-next-test", - `test("hangs", () => { + test("a test after one that spins on .resolves still runs", async () => { + const { stderr, exitCode, hung } = await runTestFile( + "issue-14950-next-test", + `test("hangs", () => { expect(new Promise(() => {})).resolves.toBe(1); }); test("runs after", () => { expect(1).toBe(1); });`, - ); - expect(hung).toBe(false); - expect(stderr).toMatch(/1 pass/); - expect(stderr).toMatch(/1 fail/); - expect(exitCode).toBe(1); - }, - 60_000, - ); + ); + expect(hung).toBe(false); + expect(stderr).toMatch(/1 pass/); + expect(stderr).toMatch(/1 fail/); + expect(exitCode).toBe(1); + }, 60_000); - test( - ".resolves on an already-resolved promise still passes synchronously", - async () => { - const { stderr, exitCode, hung } = await runTestFile( - "issue-14950-settled", - `test("already settled", () => { + test(".resolves on an already-resolved promise still passes synchronously", async () => { + const { stderr, exitCode, hung } = await runTestFile( + "issue-14950-settled", + `test("already settled", () => { expect(Promise.resolve(25)).resolves.toBe(25); });`, - ); - expect(hung).toBe(false); - expect(stderr).toMatch(/1 pass/); - expect(stderr).not.toContain("still pending"); - expect(exitCode).toBe(0); - }, - 60_000, - ); + ); + expect(hung).toBe(false); + expect(stderr).toMatch(/1 pass/); + expect(stderr).not.toContain("still pending"); + expect(exitCode).toBe(0); + }, 60_000); }); From 6b7fd11f0d0fa7caf6d50b12f8d2db8f5f72b3f7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:02:30 +0000 Subject: [PATCH 3/6] trim doc comments on wait_for_promise_bounded_by_test --- src/runtime/test_runner/expect.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index f0ca452133be..730671d5f3df 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -471,12 +471,8 @@ impl Expect { } } - /// Spin the event loop until `promise` settles, like `wait_for_promise`, - /// but stop once the enclosing test's timeout deadline passes so a matcher - /// awaiting a promise that can only settle *after* the matcher returns - /// (e.g. `expect(p).resolves.toBe(x); resolve(x);`) turns into a test - /// timeout instead of a 100% CPU hang. Returns `true` if the promise is - /// still pending when the spin ends. + /// `wait_for_promise` bounded by the enclosing test's deadline. + /// Returns `true` if the promise is still pending when the spin stops. fn wait_for_promise_bounded_by_test( global_this: &JSGlobalObject, promise: bun_jsc::AnyPromise, @@ -485,8 +481,6 @@ impl Expect { if promise.status() != js_promise::Status::Pending { return false; } - // `on_stack_entry` is set for the duration of `run_test_callback`, so the - // deadline is stable across the spin; read it once. EPOCH = no timeout. let deadline = Jest::runner().map_or(Timespec::EPOCH, |r| r.get_active_timeout()); let jsc_vm = global_this.vm(); let bun_vm = global_this.bun_vm().as_mut(); From ad31381104a622a8418f555ef76b6d70a26acc73 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:08:10 +0000 Subject: [PATCH 4/6] collapse doc comment to one line --- src/runtime/test_runner/expect.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index 730671d5f3df..f508f25e826e 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -471,8 +471,7 @@ impl Expect { } } - /// `wait_for_promise` bounded by the enclosing test's deadline. - /// Returns `true` if the promise is still pending when the spin stops. + /// `wait_for_promise` bounded by the test's deadline; returns `true` if still pending. fn wait_for_promise_bounded_by_test( global_this: &JSGlobalObject, promise: bun_jsc::AnyPromise, From c14e9e3bc9f90d2a7a71236dc4c4fd5f6b3144fc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:28:57 +0000 Subject: [PATCH 5/6] mark toThrow async-fn promise handled before the bounded wait The still_pending early return skips unwrap(MarkHandled), so a promise abandoned at the test deadline that later rejects would surface as an unhandled rejection attributed to whichever test is running then. Mark it handled up front like the process_promise and execute_custom_matcher siblings already do, and add a regression case. --- src/runtime/test_runner/expect.rs | 1 + test/regression/issue/14950.test.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index f508f25e826e..14031302c64e 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -932,6 +932,7 @@ impl Expect { } if let Some(promise) = return_value.as_any_promise() { + promise.set_handled(global_this.vm()); let still_pending = Self::wait_for_promise_bounded_by_test(global_this, promise); scope.apply(vm); if still_pending { diff --git a/test/regression/issue/14950.test.ts b/test/regression/issue/14950.test.ts index 663fe2913d6e..44b2827d9756 100644 --- a/test/regression/issue/14950.test.ts +++ b/test/regression/issue/14950.test.ts @@ -102,6 +102,24 @@ describe.concurrent("expect().resolves/.rejects on a not-yet-settled promise", ( expect(exitCode).toBe(1); }, 60_000); + test("a late rejection from an abandoned toThrow async fn does not fail the next test", async () => { + const { stderr, exitCode, hung } = await runTestFile( + "issue-14950-late-reject", + `test("a", () => { + expect(async () => { await Bun.sleep(700); throw new Error("late"); }).toThrow(); + }); + test("b", async () => { + await Bun.sleep(900); + expect(1).toBe(1); + }, 5000);`, + ); + expect(hung).toBe(false); + expect(stderr).toMatch(/1 pass/); + expect(stderr).toMatch(/1 fail/); + expect(stderr).not.toContain("Unhandled"); + expect(exitCode).toBe(1); + }, 60_000); + test(".resolves on an already-resolved promise still passes synchronously", async () => { const { stderr, exitCode, hung } = await runTestFile( "issue-14950-settled", From 0ebfa04ff6d31590b3782b9eaea2e47502d203b1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:31:56 +0000 Subject: [PATCH 6/6] ci: retrigger