diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 753a2eaecb08..411f6bf4eff9 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4603,6 +4603,8 @@ impl VirtualMachine { self.wait_for_promise(jsc::AnyPromise::Internal(promise)); } + // Pre-arm the waker so this settled-promise tick cannot park (#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..9b7bd22480a0 --- /dev/null +++ b/test/regression/issue/36450.test.ts @@ -0,0 +1,76 @@ +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); + globalThis.__timerArmed36450 = true; + 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}"; + // 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)); + 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, ISSUE_36450_EXPECT_TIMER: withLeak ? "1" : "0" }, + 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])); + } + // 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)]; + } + + 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); +});