From 07ca26c15c04a8726c69364bf62caf722dd843f4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:33:54 +0000 Subject: [PATCH 1/6] bun test: don't park the event loop after a test file's entry promise settles With a ref'd handle pending (e.g. a user setTimeout left running by an earlier test file), the final auto_tick in load_entry_point_for_test_runner kept the loop active and parked in the poller until the next timer deadline, typically JSC's incremental sweeper armed ~100ms out by the per-file GC. Every subsequent test file that loaded a module paid that wait. Pre-arm the waker so the tick drains I/O without blocking, matching the existing wakeup before the entry-point load. Fixes #36450 --- src/jsc/VirtualMachine.rs | 6 +++ test/regression/issue/36450.test.ts | 65 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 test/regression/issue/36450.test.ts diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 753a2eaecb08..2d8d137d6f50 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4603,6 +4603,12 @@ impl VirtualMachine { self.wait_for_promise(jsc::AnyPromise::Internal(promise)); } + // The entry promise has settled and the test runner continues + // synchronously, so pre-arm the waker: otherwise, when a ref'd handle + // (e.g. a pending user timer) keeps the loop active, this final + // `auto_tick` parks until the next timer deadline, typically JSC's + // incremental sweeper armed ~100ms out by the per-file GC (#36450). + self.wakeup(); self.auto_tick(); Ok(self.pending_internal_promise.unwrap()) } diff --git a/test/regression/issue/36450.test.ts b/test/regression/issue/36450.test.ts new file mode 100644 index 000000000000..597649da742e --- /dev/null +++ b/test/regression/issue/36450.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// https://github.com/oven-sh/bun/issues/36450 +// A pending ref'd timer left behind by one test file made every subsequent +// test file that loads a module stall until the next JSC housekeeping timer +// (~100ms): after the file's entry promise settled, the runner's final +// blocking tick parked in the poller because the ref'd timer kept the loop +// active. The stall sits between module evaluation and the first test +// callback, so that gap is what we measure, paired against a control run +// without the pending timer so machine speed cancels out. +test("pending ref'd timer does not stall subsequent test files", async () => { + const fileCount = 6; + const files: Record = { + "leak.test.ts": ` + import { test, expect } from "bun:test"; + test("leaves one pending ref'd timer", () => { + setTimeout(() => {}, 300_000); + expect(1).toBe(1); + }); + `, + }; + for (let i = 1; i <= fileCount; i++) { + // The import must be used, otherwise it is elided and no module loads. + files[`mod${i}.ts`] = `export const v = ${i};`; + files[`plain${i}.test.ts`] = ` + import { test, expect } from "bun:test"; + import { v } from "./mod${i}"; + const loadedAt = performance.now(); + test("t${i}", () => { + console.log("GAP${i}:" + (performance.now() - loadedAt).toFixed(2)); + expect(v).toBe(${i}); + }); + `; + } + using dir = tempDir("issue-36450", files); + const plainFiles = Array.from({ length: fileCount }, (_, i) => `plain${i + 1}.test.ts`); + + async function medianGap(withLeak: boolean): Promise { + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", ...(withLeak ? ["leak.test.ts"] : []), ...plainFiles], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const gaps: number[] = []; + for (const match of (stdout + stderr).matchAll(/GAP\d+:([\d.]+)/g)) { + gaps.push(Number(match[1])); + } + expect(gaps).toHaveLength(fileCount); + expect(exitCode).toBe(0); + return gaps.toSorted((a, b) => a - b)[Math.floor(gaps.length / 2)]; + } + + const withoutTimer = await medianGap(false); + const withTimer = await medianGap(true); + + // Unfixed, the pending timer pins every gap to the next JSC timer deadline + // (~15ms release, ~90ms debug on an idle machine); fixed, both runs behave + // identically. + expect(withTimer - withoutTimer).toBeLessThan(10); +}); From 7ad69c41c9ed2059f59b2070e0f0b24e2e6e0a96 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:38:00 +0000 Subject: [PATCH 2/6] test: guard that the leak fixture armed its timer before measured files --- test/regression/issue/36450.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/regression/issue/36450.test.ts b/test/regression/issue/36450.test.ts index 597649da742e..386ab350fd97 100644 --- a/test/regression/issue/36450.test.ts +++ b/test/regression/issue/36450.test.ts @@ -16,6 +16,7 @@ test("pending ref'd timer does not stall subsequent test files", async () => { import { test, expect } from "bun:test"; test("leaves one pending ref'd timer", () => { setTimeout(() => {}, 300_000); + globalThis.__timerArmed36450 = true; expect(1).toBe(1); }); `, @@ -26,6 +27,11 @@ test("pending ref'd timer does not stall subsequent test files", async () => { files[`plain${i}.test.ts`] = ` import { test, expect } from "bun:test"; import { v } from "./mod${i}"; + // All files share one process; prove the leak file ran first so the + // measured run really has a pending ref'd timer. + if (process.env.ISSUE_36450_EXPECT_TIMER === "1" && !globalThis.__timerArmed36450) { + throw new Error("expected leak.test.ts to arm its timer before this file"); + } const loadedAt = performance.now(); test("t${i}", () => { console.log("GAP${i}:" + (performance.now() - loadedAt).toFixed(2)); @@ -39,7 +45,7 @@ test("pending ref'd timer does not stall subsequent test files", async () => { async function medianGap(withLeak: boolean): Promise { await using proc = Bun.spawn({ cmd: [bunExe(), "test", ...(withLeak ? ["leak.test.ts"] : []), ...plainFiles], - env: bunEnv, + env: { ...bunEnv, ISSUE_36450_EXPECT_TIMER: withLeak ? "1" : "0" }, cwd: String(dir), stdout: "pipe", stderr: "pipe", From 22c255c8ca2f6bf87abcf12457f0fdcb6e600a3e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:39:45 +0000 Subject: [PATCH 3/6] Shorten the pre-arm comment --- src/jsc/VirtualMachine.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 2d8d137d6f50..e3a1b52b934d 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4603,11 +4603,8 @@ impl VirtualMachine { self.wait_for_promise(jsc::AnyPromise::Internal(promise)); } - // The entry promise has settled and the test runner continues - // synchronously, so pre-arm the waker: otherwise, when a ref'd handle - // (e.g. a pending user timer) keeps the loop active, this final - // `auto_tick` parks until the next timer deadline, typically JSC's - // incremental sweeper armed ~100ms out by the per-file GC (#36450). + // The promise has settled; pre-arm the waker so this tick drains I/O + // without parking until the next timer deadline (#36450). self.wakeup(); self.auto_tick(); Ok(self.pending_internal_promise.unwrap()) From 1e9ceccb25e06ed613a973eeeb9333f3c12522a3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:41:47 +0000 Subject: [PATCH 4/6] Make the pre-arm comment one line --- src/jsc/VirtualMachine.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index e3a1b52b934d..411f6bf4eff9 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4603,8 +4603,7 @@ impl VirtualMachine { self.wait_for_promise(jsc::AnyPromise::Internal(promise)); } - // The promise has settled; pre-arm the waker so this tick drains I/O - // without parking until the next timer deadline (#36450). + // Pre-arm the waker so this settled-promise tick cannot park (#36450). self.wakeup(); self.auto_tick(); Ok(self.pending_internal_promise.unwrap()) From 087b7b04a74a2adfd2c97c0a1107cdce4504c1af Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:52:00 +0000 Subject: [PATCH 5/6] ci: retrigger From 0d7b612a441113d8d734ed2c12b7d15d3bf75320 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:07:40 +0000 Subject: [PATCH 6/6] test: surface child stderr when the gap count or exit code mismatches --- test/regression/issue/36450.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/regression/issue/36450.test.ts b/test/regression/issue/36450.test.ts index 386ab350fd97..9b7bd22480a0 100644 --- a/test/regression/issue/36450.test.ts +++ b/test/regression/issue/36450.test.ts @@ -56,8 +56,13 @@ test("pending ref'd timer does not stall subsequent test files", async () => { for (const match of (stdout + stderr).matchAll(/GAP\d+:([\d.]+)/g)) { gaps.push(Number(match[1])); } - expect(gaps).toHaveLength(fileCount); - expect(exitCode).toBe(0); + // Include stderr so a child failure (e.g. the fixture guard) prints its + // own error instead of just a short gap count. + expect({ gapCount: gaps.length, exitCode, stderr }).toEqual({ + gapCount: fileCount, + exitCode: 0, + stderr: expect.any(String), + }); return gaps.toSorted((a, b) => a - b)[Math.floor(gaps.length / 2)]; }