cloudflare / cloudflare/workers-sdk
vitest-pool-workers: every rejecting Durable Object RPC leaks one unhandled rejection, even when the test handles it
- Dominant language
- TypeScript
- Stars
- 4.5k
- Forks
- 1.5k
- Avg merge
- 3d 8h
- Merged PRs (30d)
- 186
Description
### Which Cloudflare product(s) does this pertain to?
Vitest integration (`@cloudflare/vitest-pool-workers`)
### What versions are you using?
| package | version |
| --- | --- |
| `@cloudflare/vitest-pool-workers` | 0.15.2 |
| `vitest` | 4.1.5 |
| `wrangler` | 4.84.1 |
| `miniflare` | 4.20260421.0 |
| node | 22.x |
| platform | macOS (darwin arm64) |
### Describe the Bug
Every rejecting Durable Object RPC call made through a stub produces **one unhandled rejection in addition to the rejection the test handles**. The extra rejection is unavoidable from the test's side: the test can `try/catch`, `await expect(...).rejects`, or attach `.catch()` directly, and the leak still happens. There is no floating promise in user code.
Vitest surfaces this as `Vitest caught N unhandled errors during the test run` plus `This might cause false positive tests`, so a suite that is logically green reports errors and a non-clean run.
The practical consequence, and the reason I'm filing rather than just working around it: **you cannot write a test that asserts a DO RPC rejects without leaking, unless you use `runInDurableObject`.** That's a surprising constraint on every DO error-path test, and it isn't documented. Reaching for the obvious `expect(stub.method()).rejects.toThrow()` costs hours before you conclude the leak isn't yours.
### Minimal reproduction
Four files, no application code.
`src/index.ts`
```ts
import { DurableObject } from 'cloudflare:workers';
export class Counter extends DurableObject {
async boom(): Promise {
throw new Error('boom from inside the DO');
}
async fine(): Promise {
return 'ok';
}
}
export default { fetch: () => new Response('ok') };
```
`wrangler.jsonc`
```jsonc
{
"name": "repro",
"main": "src/index.ts",
"compatibility_date": "2026-04-01",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": { "bindings": [{ "name": "COUNTER", "class_name": "Counter" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }]
}
```
`vitest.config.ts`
```ts
import { defineConfig } from 'vitest/config';
import { cloudflareTest, cloudflarePool } from '@cloudflare/vitest-pool-workers';
export default defineConfig({
plugins: [cloudflareTest({ wrangler: { configPath: './wrangler.jsonc' } })],
test: {
globals: true,
include: ['src/**/*.test.ts'],
pool: cloudflarePool({ wrangler: { configPath: './wrangler.jsonc' } }),
},
});
```
`src/repro.test.ts`
```ts
import { env, runInDurableObject } from 'cloudflare:test';
import { it, expect } from 'vitest';
// The rejection is fully handled here. Nothing floats in this file.
it('A: try/catch around a rejecting DO RPC', async () => {
const stub = env.COUNTER.get(env.COUNTER.idFromName('a'));
let caught: unknown;
try {
await stub.boom();
} catch (e) {
caught = e;
}
expect((caught as Error).message).toContain('boom');
});
it('B: expect(...).rejects, the idiomatic vitest form', async () => {
const stub = env.COUNTER.get(env.COUNTER.idFromName('b'));
await expect(stub.boom()).rejects.toThrow('boom');
});
it('C: .catch() attached directly to the returned promise', async () => {
const stub = env.COUNTER.get(env.COUNTER.idFromName('c'));
const msg = await stub.boom().catch((e: Error) => e.message);
expect(msg).toContain('boom');
});
it('D: runInDurableObject (the pool escape hatch)', async () => {
const stub = env.COUNTER.get(env.COUNTER.idFromName('d'));
await runInDurableObject(stub, async (instance: any) => {
await expect(instance.boom()).rejects.toThrow('boom');
});
});
```
`npx vitest run` — all 4 tests pass, and the run reports errors:
```
uncaught exception; source = Uncaught (in promise); stack = Error: boom from inside the DO
at Counter.boom (src/index.ts:6:11)
at DurableObject.fn (node_modules/@cloudflare/vitest-pool-workers/dist/worker/lib/cloudflare/test-internal.mjs:362:45)
... (repeated for A, B and C; D does not leak)
⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯
Vitest caught 1 unhandled error during the test run.
This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.
⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
Error: boom from inside the DO
Serialized Error: { remote: true }
Test Files 1 passed (1)
Tests 4 passed (4)
Errors 1 error
```
### Isolation
Each idiom in its own file, run alone, counting `source = Uncaught (in promise)` lines:
| # | test | escaping rejections |
| --- | --- | --- |
| A | `try { await stub.boom() } catch {}` | **1** |
| B | `await expect(stub.boom()).rejects.toThrow()` | **1** |
| C | `await stub.boom().catch(...)` | **1** |
| D | `runInDurableObject(stub, i => expect(i.boom()).rejects...)` | 0 |
| E | control: `await stub.fine()` (resolves, never rejects) | 0 |
| F | two handled rejecting calls in one test | **2** |
| G | property access only — `const f = stub.boom`, never called | 0 |
So: **exactly one leaked rejection per rejecting call**, only via the stub path, only when the RPC actually rejects. Property access alone (G) is clean, which rules out the property-lookup promise. `runInDurableObject` (D) bypasses the wrapper and is clean.
### Where it comes from
The escaping rejection's stack frame is `test-internal.mjs:362`, inside `getRPCPropertyCallableThenable`:
https://github.com/cloudflare/workers-sdk/blob/main/packages/vitest-pool-workers/src/worker/lib/cloudflare/test-internal.ts
```js
function getRPCPropertyCallableThenable(key, property) {
const fn = async function (...args) {
const maybeFn = await property;
if (typeof maybeFn === "function") return maybeFn(...args); // <- line 362, the leak's frame
else throw new TypeError(`${JSON.stringify(key)} is not a function.`);
};
fn.then = (onFulfilled, onRejected) => property.then(onFulfilled, onRejected);
fn.catch = (onRejected) => property.catch(onRejected);
fn.finally = (onFinally) => property.finally(onFinally);
return fn;
}
```
`createDurableObjectWrapper` routes every non-reserved property through this helper, so every DO RPC goes through `fn`:
```js
const Wrapper = createProxyPrototypeClass(DurableObject, function (key) {
if (WORKER_ENTRYPOINT_KEYS.includes(key)) return;
return getRPCPropertyCallableThenable(key, getDurableObjectRPCProperty(this, className, key));
});
```
I want to be careful to separate what I measured from what I'm guessing:
- **Measured:** the leak is one-per-rejecting-call; it is not the `property` promise (G is clean); the frame is `maybeFn(...args)`; `runInDurableObject`, which does not go through this wrapper, is clean.
- **Hypothesis (unverified):** the object handed to the RPC layer is both *callable* and *thenable*. The rejection produced by `maybeFn(...args)` appears to be observed on one path (the one the caller awaits, which is why the test's `catch` works) while a second reference to the same rejected promise is left without a handler. `createWorkerEntrypointWrapper` and `createWorkflowEntrypointWrapper` use the same helper, so I'd expect the same behaviour for `SELF`/WorkerEntrypoint and Workflow RPC, though I only measured Durable Objects.
### Expected behaviour
A DO RPC whose rejection is handled by the caller should not produce an unhandled rejection. `await expect(stub.method()).rejects.toThrow()` should leave a clean run.
### Workaround, for anyone who lands here
Use the pool's own escape hatch — it's the only idiom I found that doesn't leak:
```ts
await runInDurableObject(stub, async (instance) => {
await expect(instance.boom()).rejects.toThrow('boom');
});
```
We hit this while cleaning up ~64 unhandled rejections escaping the workers pool in our suite. We converted one file to bulletproof `try/catch` around every DO RPC and the error count went **8 → 4, not to zero**, which is what sent us into the pool's internals. Rewriting those assertions onto `runInDurableObject` was what actually fixed it.
If the double-observation is intended and `runInDurableObject` is the sanctioned way to assert on DO RPC errors, a note in the Durable Objects testing docs would save the next person the same dig.
Contributor guide
Research direction
Start in packages/vitest-pool-workers/src/worker/lib/cloudflare/test-internal.ts, especially getRPCPropertyCallableThenable and createDurableObjectWrapper. Run the four-file reproduction with npx vitest run and trace the rejection from maybeFn(...args), comparing the stub path with runInDurableObject. Done means handled rejecting DO RPC calls pass without any Vitest unhandled-error reports, while resolving calls remain clean.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend, testing
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100