From d289fd7efe7859e5224f8abed8e997040a3a5ff2 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 27 Mar 2026 00:09:26 +0000 Subject: [PATCH 1/5] [vitest-pool-workers] Add 30s timeout to waitUntil promise draining Previously, if a ctx.waitUntil() promise never resolved, the test suite would hang indefinitely after the test file finished. Now, any waitUntil promises that haven't settled within 30 seconds (aligned with the production Workers platform limit) are abandoned with a warning. --- .../vitest-pool-workers-waituntil-timeout.md | 7 +++ .../src/worker/wait-until.ts | 47 +++++++++++++++++-- .../test/wait-until-timeout.test.ts | 47 +++++++++++++++++++ 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 .changeset/vitest-pool-workers-waituntil-timeout.md create mode 100644 packages/vitest-pool-workers/test/wait-until-timeout.test.ts diff --git a/.changeset/vitest-pool-workers-waituntil-timeout.md b/.changeset/vitest-pool-workers-waituntil-timeout.md new file mode 100644 index 0000000000..01ad48f2fa --- /dev/null +++ b/.changeset/vitest-pool-workers-waituntil-timeout.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/vitest-pool-workers": patch +--- + +Add a 30-second timeout to `waitUntil` promise draining to prevent hanging tests + +Previously, if a `ctx.waitUntil()` promise never resolved, the test suite would hang indefinitely after the test file finished. Now, any `waitUntil` promises that haven't settled within 30 seconds are abandoned with a warning, allowing the test suite to continue. This aligns with the [production `waitUntil` limit](https://developers.cloudflare.com/workers/platform/limits/#duration). diff --git a/packages/vitest-pool-workers/src/worker/wait-until.ts b/packages/vitest-pool-workers/src/worker/wait-until.ts index 3f86fedbf0..af79327346 100644 --- a/packages/vitest-pool-workers/src/worker/wait-until.ts +++ b/packages/vitest-pool-workers/src/worker/wait-until.ts @@ -1,9 +1,28 @@ import { AsyncLocalStorage } from "node:async_hooks"; +/** + * In production, Workers have a 30-second limit for `waitUntil` promises. + * We use the same limit here. If promises are still pending after this, + * they almost certainly indicate a bug (e.g. a `waitUntil` promise that + * will never resolve). We log a warning and move on so the test suite + * doesn't hang indefinitely. + */ +let WAIT_UNTIL_TIMEOUT = 30_000; + +/** @internal — only exposed for tests */ +export function setWaitUntilTimeout(ms: number): void { + WAIT_UNTIL_TIMEOUT = ms; +} + +const kTimedOut = Symbol("kTimedOut"); + /** * Empty array and wait for all promises to resolve until no more added. * If a single promise rejects, the rejection will be passed-through. * If multiple promises reject, the rejections will be aggregated. + * + * If any batch of promises hasn't settled after {@link WAIT_UNTIL_TIMEOUT}ms, + * a warning is logged and the remaining promises are abandoned. */ export async function waitForWaitUntil( /* mut */ waitUntil: unknown[] @@ -11,11 +30,31 @@ export async function waitForWaitUntil( const errors: unknown[] = []; while (waitUntil.length > 0) { - const results = await Promise.allSettled(waitUntil.splice(0)); + const batch = waitUntil.splice(0); + const result = await Promise.race([ + Promise.allSettled(batch).then((results) => ({ results })), + new Promise((resolve) => + setTimeout(() => resolve(kTimedOut), WAIT_UNTIL_TIMEOUT) + ), + ]); + + if (result === kTimedOut) { + __console.warn( + `[vitest-pool-workers] ${batch.length} waitUntil promise(s) did not ` + + `resolve within ${WAIT_UNTIL_TIMEOUT / 1000}s and will be abandoned. ` + + `This normally means your Worker's waitUntil handler has a bug ` + + `that prevents it from settling (e.g. a fetch that never completes ` + + `or a missing resolve/reject call).` + ); + // Stop draining — any promises added during this batch are also abandoned + waitUntil.length = 0; + break; + } + // Record all rejected promises - for (const result of results) { - if (result.status === "rejected") { - errors.push(result.reason); + for (const settled of result.results) { + if (settled.status === "rejected") { + errors.push(settled.reason); } } } diff --git a/packages/vitest-pool-workers/test/wait-until-timeout.test.ts b/packages/vitest-pool-workers/test/wait-until-timeout.test.ts new file mode 100644 index 0000000000..9cdd8f807b --- /dev/null +++ b/packages/vitest-pool-workers/test/wait-until-timeout.test.ts @@ -0,0 +1,47 @@ +import dedent from "ts-dedent"; +import { test, vitestConfig } from "./helpers"; + +test( + "abandons waitUntil promises that never resolve and logs a warning", + async ({ expect, seed, vitestRun }) => { + await seed({ + "vitest.config.mts": vitestConfig({ + main: "./index.ts", + miniflare: { + compatibilityDate: "2025-12-02", + compatibilityFlags: ["nodejs_compat"], + }, + }), + "index.ts": dedent` + export default { + fetch(request, env, ctx) { + // Register a waitUntil promise that will never resolve + ctx.waitUntil(new Promise(() => {})); + return new Response("ok"); + } + } + `, + "index.test.ts": dedent` + import { SELF } from "cloudflare:test"; + import { setWaitUntilTimeout } from "cloudflare:test-internal"; + import { beforeAll, expect, it } from "vitest"; + + beforeAll(() => { + // Use a short timeout so the test doesn't take 30s + setWaitUntilTimeout(100); + }); + + it("sends request with never-resolving waitUntil", async () => { + const response = await SELF.fetch("https://example.com"); + expect(response.ok).toBe(true); + }); + `, + }); + const result = await vitestRun(); + expect(await result.exitCode).toBe(0); + const output = result.stdout + result.stderr; + expect(output).toContain( + "waitUntil promise(s) did not resolve within" + ); + } +); From e7e6a92240bd0a7e54fb6df48e40f048c0295f60 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 27 Mar 2026 00:18:57 +0000 Subject: [PATCH 2/5] [vitest-pool-workers] Fix test to call waitForWaitUntil directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous test went through SELF.fetch() which returns immediately — waitForGlobalWaitUntil() was never called in that lifecycle. The new test calls waitForWaitUntil() directly with a never-resolving promise and verifies it returns after the timeout instead of hanging. --- .../test/wait-until-timeout.test.ts | 39 ++++++------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/packages/vitest-pool-workers/test/wait-until-timeout.test.ts b/packages/vitest-pool-workers/test/wait-until-timeout.test.ts index 9cdd8f807b..2f71a875a7 100644 --- a/packages/vitest-pool-workers/test/wait-until-timeout.test.ts +++ b/packages/vitest-pool-workers/test/wait-until-timeout.test.ts @@ -2,38 +2,23 @@ import dedent from "ts-dedent"; import { test, vitestConfig } from "./helpers"; test( - "abandons waitUntil promises that never resolve and logs a warning", + "waitForWaitUntil abandons promises that never resolve", async ({ expect, seed, vitestRun }) => { await seed({ - "vitest.config.mts": vitestConfig({ - main: "./index.ts", - miniflare: { - compatibilityDate: "2025-12-02", - compatibilityFlags: ["nodejs_compat"], - }, - }), - "index.ts": dedent` - export default { - fetch(request, env, ctx) { - // Register a waitUntil promise that will never resolve - ctx.waitUntil(new Promise(() => {})); - return new Response("ok"); - } - } - `, + "vitest.config.mts": vitestConfig(), "index.test.ts": dedent` - import { SELF } from "cloudflare:test"; - import { setWaitUntilTimeout } from "cloudflare:test-internal"; - import { beforeAll, expect, it } from "vitest"; + import { + setWaitUntilTimeout, + waitForWaitUntil, + } from "cloudflare:test-internal"; + import { expect, it } from "vitest"; - beforeAll(() => { - // Use a short timeout so the test doesn't take 30s + it("returns after timeout instead of hanging", async () => { setWaitUntilTimeout(100); - }); - - it("sends request with never-resolving waitUntil", async () => { - const response = await SELF.fetch("https://example.com"); - expect(response.ok).toBe(true); + const waitUntil = [new Promise(() => {})]; + await waitForWaitUntil(waitUntil); + // If we get here, the timeout worked — the function didn't hang + expect(waitUntil).toHaveLength(0); }); `, }); From b7f4575a57cee165dd00a3284f573526f91394e4 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 27 Mar 2026 00:24:21 +0000 Subject: [PATCH 3/5] [vitest-pool-workers] Clear timeout timer when Promise.allSettled wins the race --- packages/vitest-pool-workers/src/worker/wait-until.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/vitest-pool-workers/src/worker/wait-until.ts b/packages/vitest-pool-workers/src/worker/wait-until.ts index af79327346..612fdb4140 100644 --- a/packages/vitest-pool-workers/src/worker/wait-until.ts +++ b/packages/vitest-pool-workers/src/worker/wait-until.ts @@ -31,12 +31,18 @@ export async function waitForWaitUntil( while (waitUntil.length > 0) { const batch = waitUntil.splice(0); + let timeoutId: ReturnType; const result = await Promise.race([ Promise.allSettled(batch).then((results) => ({ results })), - new Promise((resolve) => - setTimeout(() => resolve(kTimedOut), WAIT_UNTIL_TIMEOUT) + new Promise( + (resolve) => + (timeoutId = setTimeout( + () => resolve(kTimedOut), + WAIT_UNTIL_TIMEOUT + )) ), ]); + clearTimeout(timeoutId!); if (result === kTimedOut) { __console.warn( From 07f2fed6e687a914d36b297e52aff0e15b9c201c Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 27 Mar 2026 00:31:17 +0000 Subject: [PATCH 4/5] [vitest-pool-workers] Fix formatting --- .../src/worker/wait-until.ts | 5 +--- .../test/wait-until-timeout.test.ts | 29 +++++++++---------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/packages/vitest-pool-workers/src/worker/wait-until.ts b/packages/vitest-pool-workers/src/worker/wait-until.ts index 612fdb4140..b3fe33b641 100644 --- a/packages/vitest-pool-workers/src/worker/wait-until.ts +++ b/packages/vitest-pool-workers/src/worker/wait-until.ts @@ -36,10 +36,7 @@ export async function waitForWaitUntil( Promise.allSettled(batch).then((results) => ({ results })), new Promise( (resolve) => - (timeoutId = setTimeout( - () => resolve(kTimedOut), - WAIT_UNTIL_TIMEOUT - )) + (timeoutId = setTimeout(() => resolve(kTimedOut), WAIT_UNTIL_TIMEOUT)) ), ]); clearTimeout(timeoutId!); diff --git a/packages/vitest-pool-workers/test/wait-until-timeout.test.ts b/packages/vitest-pool-workers/test/wait-until-timeout.test.ts index 2f71a875a7..4b8057762b 100644 --- a/packages/vitest-pool-workers/test/wait-until-timeout.test.ts +++ b/packages/vitest-pool-workers/test/wait-until-timeout.test.ts @@ -1,12 +1,14 @@ import dedent from "ts-dedent"; import { test, vitestConfig } from "./helpers"; -test( - "waitForWaitUntil abandons promises that never resolve", - async ({ expect, seed, vitestRun }) => { - await seed({ - "vitest.config.mts": vitestConfig(), - "index.test.ts": dedent` +test("waitForWaitUntil abandons promises that never resolve", async ({ + expect, + seed, + vitestRun, +}) => { + await seed({ + "vitest.config.mts": vitestConfig(), + "index.test.ts": dedent` import { setWaitUntilTimeout, waitForWaitUntil, @@ -21,12 +23,9 @@ test( expect(waitUntil).toHaveLength(0); }); `, - }); - const result = await vitestRun(); - expect(await result.exitCode).toBe(0); - const output = result.stdout + result.stderr; - expect(output).toContain( - "waitUntil promise(s) did not resolve within" - ); - } -); + }); + const result = await vitestRun(); + expect(await result.exitCode).toBe(0); + const output = result.stdout + result.stderr; + expect(output).toContain("waitUntil promise(s) did not resolve within"); +}); From 69a3a4131a1c6fe48c7fb14656d3ffd65a2a8a5d Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 27 Mar 2026 00:32:38 +0000 Subject: [PATCH 5/5] [vitest-pool-workers] Fix non-null assertion lint error --- packages/vitest-pool-workers/src/worker/wait-until.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vitest-pool-workers/src/worker/wait-until.ts b/packages/vitest-pool-workers/src/worker/wait-until.ts index b3fe33b641..a88f2c1f23 100644 --- a/packages/vitest-pool-workers/src/worker/wait-until.ts +++ b/packages/vitest-pool-workers/src/worker/wait-until.ts @@ -31,7 +31,7 @@ export async function waitForWaitUntil( while (waitUntil.length > 0) { const batch = waitUntil.splice(0); - let timeoutId: ReturnType; + let timeoutId: ReturnType | undefined; const result = await Promise.race([ Promise.allSettled(batch).then((results) => ({ results })), new Promise( @@ -39,7 +39,7 @@ export async function waitForWaitUntil( (timeoutId = setTimeout(() => resolve(kTimedOut), WAIT_UNTIL_TIMEOUT)) ), ]); - clearTimeout(timeoutId!); + clearTimeout(timeoutId); if (result === kTimedOut) { __console.warn(