Skip to content

Instantly share code, notes, and snippets.

@lmaccherone
Created June 4, 2026 12:42
Show Gist options
  • Select an option

  • Save lmaccherone/e6f49cf2e7fa4a0cb9efe03a4b1c2feb to your computer and use it in GitHub Desktop.

Select an option

Save lmaccherone/e6f49cf2e7fa4a0cb9efe03a4b1c2feb to your computer and use it in GitHub Desktop.
Repro: @cloudflare/vitest-pool-workers hangs at teardown when a DO blockConcurrencyWhile IIFE both emits a console.* call and throws

Repro: @cloudflare/vitest-pool-workers hangs at teardown when a DO blockConcurrencyWhile IIFE both emits a console.* call and throws

Summary

A DurableObject whose constructor schedules ctx.blockConcurrencyWhile(async () => { ... }), and whose IIFE both (a) emits a console.* call and (b) throws, causes @cloudflare/vitest-pool-workers to hang at isolate teardown.

The test assertion passes — the hang happens after the test resolves, during vitest's process exit.

Removing either the console.* call or the throw makes vitest exit cleanly.

The hang is vitest-only. The same Worker deployed to *.workers.dev handles the same broken DO cleanly: workerd evicts the broken instance and recreates it on the next request. Other DO instances are unaffected.

File layout

Files in this gist are flat. Reproduce as:

onstart-repro/
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── wrangler.jsonc
├── src/
│   └── index.ts              # `worker.ts` in this gist
└── test/
    └── onstart-throw.test.ts # `onstart-throw.test.ts` in this gist

wrangler.jsonc references ./src/index.ts. Adjust the main field if you want everything flat (e.g., "./worker.ts").

Run

npm install
npm test

Expected: the test passes (✓) and then the process hangs, requiring Ctrl-C to exit.

Make it pass cleanly

Delete the console.log(...) line in src/index.ts:

ctx.blockConcurrencyWhile(async () => {
-  console.log('emit anything before the throw to trigger the hang');
   throw new Error('Intentional throw in blockConcurrencyWhile');
});

Now npm test exits in ~300 ms with the test still passing.

Environment

  • vitest@4.1.4
  • @cloudflare/vitest-pool-workers@0.16.13
  • wrangler@4.86.0
  • compatibility_date: "2026-03-12"
  • macOS Darwin 25.3.0, Node 22 LTS

Production check (NOT vitest)

A worker with the same DO class was deployed to a real *.workers.dev host. All requests to the broken instance returned a 500 with the constructor's error message in 200-500 ms each (cold-start range). 20 parallel requests all returned the same error cleanly; a sibling healthy DO returned in 21 ms warm immediately afterward. Workerd evicts and recreates the broken DO on each request — there is no permanent input-gate wedge in production.

Hypothesis

vitest-pool-workers's isolate-shutdown path appears to await drainage of the input gate for any DO that had pending work. A DO whose constructor's blockConcurrencyWhile rejected AFTER emitting console output is left in a state the teardown can't drain. Workerd itself doesn't sit on this state in production — it just evicts.

Bisection notes

We bisected from a real product setup (a LumenizeDO subclass whose onStart() throws) down to the minimum trigger:

Setup Result
Plain DurableObject, blockConcurrencyWhile throws, no console call exits ~300 ms
Plain DurableObject, throw + post-throw SQL, no console call exits ~420 ms
Plain DurableObject, projects-based vitest config exits
Plain DurableObject, several tests in file, no console call exits
Plain DurableObject, + console.log(...) inside the IIFE before throw hangs >45 s
Same, with console.debug(JSON.stringify(...)) instead of console.log hangs
Same trigger, without try/catch wrapping the throw hangs

Two necessary conditions: synchronous console output AND a throw in the same IIFE.

import { env } from 'cloudflare:test';
import { describe, expect, it } from 'vitest';
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
describe('DO constructor blockConcurrencyWhile throws after console.debug', () => {
it('test passes, then vitest hangs at teardown', async () => {
const stub = env.BROKEN.getByName('broken-1');
await expect(stub.getValue()).rejects.toThrow(
'Intentional throw in blockConcurrencyWhile'
);
});
});
{
"name": "@lumenize/onstart-repro",
"version": "0.0.0",
"private": true,
"description": "Repro for: DO whose blockConcurrencyWhile throws during constructor leaves vitest-pool-workers hung at teardown",
"type": "module",
"scripts": {
"deploy": "wrangler deploy --config ./wrangler.jsonc",
"tail": "wrangler tail --config ./wrangler.jsonc",
"delete": "wrangler delete --config ./wrangler.jsonc",
"test": "vitest run",
"types": "wrangler types"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.16.13",
"vitest": "4.1.4",
"wrangler": "^4.86.0"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["@cloudflare/vitest-pool-workers"]
},
"include": ["src/**/*", "test/**/*", "worker-configuration.d.ts"]
}
import { defineConfig } from 'vitest/config';
import { cloudflareTest } from '@cloudflare/vitest-pool-workers';
export default defineConfig({
test: {
globals: true,
dangerouslyIgnoreUnhandledErrors: true,
projects: [
{
extends: true,
plugins: [
cloudflareTest({
wrangler: { configPath: './wrangler.jsonc' },
}),
],
test: {
name: 'main',
include: ['test/**/*.test.ts'],
},
},
],
},
});
import { DurableObject } from 'cloudflare:workers';
/**
* Minimum repro: a DurableObject constructor that calls a side-effect
* (console.log) inside the blockConcurrencyWhile IIFE before throwing.
*
* Observed:
* - With the console.log line, vitest-pool-workers hangs at teardown after
* the assertion completes. The test itself passes.
* - Removing the console.log line makes vitest exit cleanly (~300ms).
* - In production (deployed to *.workers.dev), both variants behave
* identically — workerd evicts the broken DO and recreates it on the
* next request. No hang. No DOS. Other DO instances are unaffected.
*
* The hang is therefore in vitest-pool-workers's isolate teardown, not in
* workerd's general handling of a broken input gate.
*/
export class BrokenDO extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
console.log('emit anything before the throw to trigger the hang');
throw new Error('Intentional throw in blockConcurrencyWhile');
});
}
async getValue(): Promise<string> {
return 'never-reached';
}
}
export default {
async fetch(_request: Request, _env: Env): Promise<Response> {
return new Response('onstart-repro', { headers: { 'content-type': 'text/plain' } });
},
} satisfies ExportedHandler<Env>;
{
"name": "onstart-repro",
"main": "./src/index.ts",
"compatibility_date": "2026-03-12",
"compatibility_flags": ["nodejs_compat_v2"],
"durable_objects": {
"bindings": [
{ "name": "BROKEN", "class_name": "BrokenDO" }
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["BrokenDO"]
}
]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment