Skip to content

[vitest-pool-workers] Treat webSocket* as optional Durable Object handlers#14763

Merged
edmundhung merged 5 commits into
cloudflare:mainfrom
gianghungtien:fix/vitest-pool-workers-optional-websocket-handlers
Jul 20, 2026
Merged

[vitest-pool-workers] Treat webSocket* as optional Durable Object handlers#14763
edmundhung merged 5 commits into
cloudflare:mainfrom
gianghungtien:fix/vitest-pool-workers-optional-websocket-handlers

Conversation

@gianghungtien

@gianghungtien gianghungtien commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Fixes #13639.

createDurableObjectWrapper() installs a prototype method for every key in DURABLE_OBJECT_KEYS (connect, fetch, alarm, webSocketMessage, webSocketClose, webSocketError) before user code is loaded. workerd probes the prototype to decide whether a Durable Object implements a handler, so it always finds one and always dispatches. The wrapper then looks the method up on the real user instance and, if it isn't there, throws:

TypeError: <ClassName> exported by <mainPath> does not define a `webSocketClose()` method

Deployed Workers treat webSocketMessage(), webSocketClose() and webSocketError() as optional — a class that omits them just has those events ignored. So a hibernatable Durable Object defining only webSocketMessage() (exactly the reporter's case) works when deployed but logs an uncaught TypeError on every close.

The change

Those three keys now no-op instead of throwing. Because the prototype is built before user code loads, the wrapper can't simply leave them undefined to inherit the runtime's behaviour — the no-op has to be reproduced explicitly.

alarm() is deliberately not in the optional set, and still reports a missing handler.

Verifying the production semantics

Rather than infer this from the docs, I checked both halves against raw workerd — driving Miniflare directly, with no pool wrapper in the picture:

  • A hibernatable DO defining no webSocket* handlers accepts a socket, receives a message and is closed with no error at all — confirming the no-op.
  • setAlarm() on a class with no alarm() handler throws Your Durable Object class must have an alarm() handler in order to call setAlarm() — confirming alarm must keep throwing. A missing alarm() can't even be reached, since the runtime blocks scheduling first.

That pins the boundary of the fix rather than leaving it to judgement.

Testing

Regression test in packages/vitest-pool-workers/test/bindings.test.ts: a seeded vitestRun() case with a Durable Object defining only webSocketMessage(), asserting the child run exits 0 and its output never mentions does not define.

This is red-to-green — without the fix the seeded run reports:

uncaught exception; source = Uncaught (in promise); stack = TypeError:
OptionalWebSocketHandlers exported by <tmp>/index.ts does not define a
`webSocketClose()` method

Two details worth noting:

  • The test polls a socketCount() RPC until the runtime has actually processed the close. The uncaught exception is logged asynchronously, so without that synchronisation the run can finish before the dispatch happens and passes either way.
  • The message arrives on stdout (it comes from workerd, not Vitest's error reporting), so the test asserts on both streams.

Also removes the empty webSocketClose() {} / webSocketError() {} stubs from the Counter example fixture. These were added in #14061 and only ever existed to satisfy this throw; dropping them means the user-facing example no longer demonstrates an unnecessary workaround, and puts the existing hibernation-ordering test on the same path as a real user's class.

Verified locally: bindings.test.ts 3/3 with the fix and failing on the new case without it; durable-objects fixture 9/9 with no uncaught exceptions; oxlint --type-aware, tsc and oxfmt clean.


  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: this aligns local test behaviour with the already-documented optional webSocket* handlers; no public API or documented behaviour changes.

A picture of a cute animal (not mandatory, but encouraged)

🦦

…dlers

The pool wraps each Durable Object class and installs a prototype method for
every key in `DURABLE_OBJECT_KEYS` before user code is loaded. `workerd`
therefore always sees a handler and always dispatches, and the wrapper threw
a TypeError when the wrapped class turned out not to define one.

That is wrong for `webSocketMessage()`, `webSocketClose()` and
`webSocketError()`, which deployed Workers treat as optional and silently
ignore when absent. A hibernatable Durable Object defining only
`webSocketMessage()` logged an uncaught TypeError on every close.

No-op these three keys instead of throwing. `alarm()` deliberately keeps the
old behaviour: `workerd` rejects `setAlarm()` up front on a class with no
`alarm()` handler, so a missing one is a genuine error.

Verified the production semantics against raw `workerd` (via Miniflare, no
pool wrapper): a hibernatable Durable Object with no `webSocket*` handlers
accepts, receives and closes a socket without error, while `setAlarm()` on a
class with no `alarm()` handler throws "Your Durable Object class must have
an alarm() handler in order to call setAlarm()".

The `Counter` fixture's empty `webSocketClose()`/`webSocketError()` stubs
were a workaround for this and are now removed.
@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7449959

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cloudflare/vitest-pool-workers Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@workers-devprod
workers-devprod requested review from a team and edmundhung and removed request for a team July 20, 2026 03:51
@workers-devprod

workers-devprod commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • ✅ @cloudflare/wrangler
Show detailed file reviewers

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@pkg-pr-new

pkg-pr-new Bot commented Jul 20, 2026

Copy link
Copy Markdown
@cloudflare/autoconfig

npm i https://pkg.pr.new/@cloudflare/autoconfig@14763

@cloudflare/config

npm i https://pkg.pr.new/@cloudflare/config@14763

create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@14763

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/@cloudflare/deploy-helpers@14763

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@14763

miniflare

npm i https://pkg.pr.new/miniflare@14763

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@14763

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@14763

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@14763

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@14763

@cloudflare/workers-auth

npm i https://pkg.pr.new/@cloudflare/workers-auth@14763

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@14763

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@14763

wrangler

npm i https://pkg.pr.new/wrangler@14763

commit: 7449959

@edmundhung edmundhung left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR! The fix looks good to me.

Could we move the test into packages/vitest-pool-workers/test/bindings.test.ts? The examples fixture is user-facing, and I believe this regression test is more about the behavior of our internal wrapper.

You can then use the vitestRun() helper to assert that stderr does not contain the TypeError.

@github-project-automation github-project-automation Bot moved this from Untriaged to In Review in workers-sdk Jul 20, 2026
Addresses review feedback: the examples fixture is user-facing, and this is a
test of the internal Durable Object wrapper rather than a recipe for users.

Reworks it as a seeded `vitestRun()` case in `test/bindings.test.ts` that
asserts the child run exits 0 and that its output never mentions
"does not define". This is a genuine red-to-green test: without the fix the
run reports `OptionalWebSocketHandlers ... does not define a
`webSocketClose()` method`.

Making it deterministic needed one extra step. The uncaught exception is
logged asynchronously, so a test that merely closes the socket can finish
before the dispatch happens. The seeded test now polls a `socketCount()` RPC
until the runtime has actually processed the close.

Reverts the fixture additions, keeping only the removal of `Counter`'s empty
`webSocketClose()`/`webSocketError()` stubs, which the fix makes unnecessary.
@gianghungtien

Copy link
Copy Markdown
Contributor Author

Thanks — done, and this turned out much better than what I had. Pushed as a separate commit rather than a force-push.

The test now lives in packages/vitest-pool-workers/test/bindings.test.ts as a seeded vitestRun() case, and I reverted the fixture additions.

Your suggestion also fixed the caveat I'd flagged in the description — it's now a genuine red-to-green regression test. Without the fix the seeded run reports:

uncaught exception; source = Uncaught (in promise); stack = TypeError:
OptionalWebSocketHandlers exported by <tmp>/index.ts does not define a
`webSocketClose()` method

Two notes on details I hit getting there, in case they're useful:

  1. It needed a synchronisation step to be deterministic. The uncaught exception is logged asynchronously, so a test that just closes the socket and ends can finish before the close is ever dispatched — the run then passes with no output either way. The seeded test polls a socketCount() RPC (ctx.getWebSockets().length) until the runtime has actually processed the close, which makes it reliable.

  2. The message lands on stdout, not stderr — it comes from workerd rather than Vitest's own error reporting. I assert on both so it's caught either way.

I've kept one thing from the fixture: the removal of Counter's empty webSocketClose() {} / webSocketError() {} stubs. They were added in #14061 purely to satisfy this throw, and since the examples are user-facing I'd rather they not demonstrate a workaround that's no longer needed — it also puts the existing hibernation-ordering test on the same path as a real user's class. Happy to drop that too if you'd prefer the PR stay strictly to the pool package.

Verified locally: bindings.test.ts 3/3 with the fix and failing on the new case without it; durable-objects fixture 9/9 with no uncaught exceptions; lint, tsc and oxfmt clean.

@edmundhung edmundhung left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@workers-devprod workers-devprod left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codeowners reviews satisfied

@github-project-automation github-project-automation Bot moved this from In Review to Approved in workers-sdk Jul 20, 2026
@gianghungtien

Copy link
Copy Markdown
Contributor Author

Thanks for the approval! This is currently blocked on Tests (Windows, packages-and-tools), which I believe is unrelated to this PR — could someone re-run that job when convenient? I only have read access, so I can't trigger it myself.

Why I think it's unrelated:

  • The failure is in @cloudflare/workflows-shared, not vitest-pool-workers, and it isn't an assertion failure — it's Error: [vitest-pool]: Worker cloudflare-pool emitted error via emitUnexpectedExit, i.e. the pool worker dying during teardown.
  • That package resolves @cloudflare/vitest-pool-workers from the catalog pin 0.13.3 rather than workspace:* (the deliberate circular-dependency workaround noted in pnpm-workspace.yaml), so the change in this PR isn't in its code path at all.
  • Linux and macOS packages-and-tools both passed — Windows only.
  • The same job passed on 1251800, which carried the identical entrypoints.ts fix. The delta since then is the test relocation (in a different package) plus the main merge, which pulled in [vitest-pool-workers] Fix module fallback failure on non-ASCII workspace paths (Windows) #14713 — itself a Windows/vitest-pool-workers change.

One thing I can't fully rule out from the logs: whether adding another seeded vitestRun() case raised resource pressure on the Windows runner enough to tip an already-fragile pool teardown over. I think it's unlikely — it's one short child run among many in that suite, and the crash is in a package that doesn't consume the workspace build — but a re-run should settle it either way. If it reproduces, I'll dig in properly rather than assume it's flaky.

@edmundhung
edmundhung merged commit 538e867 into cloudflare:main Jul 20, 2026
107 of 113 checks passed
@github-project-automation github-project-automation Bot moved this from Approved to Done in workers-sdk Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Vitest + DOs + WebSockets

3 participants