Durable Object: an outbound WebSocket opened after the request has responded fails with "isolate stopped reading WebSocket"
futurepaul opened ·
The following is AI-written (Claude Opus 5.5). It found this in our e2e suite, reduced it to the repro below, and checked it against workerd.
## Summary
In a Durable Object, a `fetch(url, { headers: { Upgrade: "websocket" } })` made from the continuation of a promise that an earlier request created, after that request's response has been sent, throws `WebSocket upgrade failed: isolate stopped reading WebSocket`. The same dial made inside the request works, and so does a plain `fetch` from the same continuation. workerd opens the socket and keeps it: a Durable Object's I/O context belongs to the object, not to the one request. (We hit it where an object opens a WebSocket when a long-lived promise it holds resolves.)
## Repro
`wrangler.jsonc`:
```jsonc { "name": "do-websocket-repro", "main": "index.js", "compatibility_date": "2026-01-01", "durable_objects": { "bindings": [{ "name": "DIALER", "class_name": "Dialer" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Dialer"] }] } ```
`index.js`:
```js import { DurableObject } from "cloudflare:workers";
export class Dialer extends DurableObject { log = [];
async dial(url) { const t0 = Date.now(); const note = (line) => this.log.push(`+${Date.now() - t0}ms ${line}`); try { const ws = (await fetch(url, { headers: { Upgrade: "websocket" } })).webSocket; ws.accept(); note("open"); ws.addEventListener("message", (e) => note(`message ${e.data}`)); ws.addEventListener("close", (e) => note(`close ${e.code} ${e.reason}`)); ws.send("1"); setTimeout(() => ws.send("2"), 1000); } catch (e) { note(`threw ${e.message}`); } }
async fetch(request) { const url = new URL(request.url); const echo = `${url.origin}/echo`; if (url.pathname === "/now") { // Dials inside this request. await this.dial(echo); return new Response("dialed\n"); } if (url.pathname === "/later") { // Dials from the continuation of a promise this request made, which // runs after the response below has been sent. new Promise((resolve) => setTimeout(resolve, 100)).then(() => this.dial(echo)); return new Response("will dial\n"); } return Response.json(this.log); } }
export default { fetch(request, env) { const url = new URL(request.url); if (url.pathname === "/echo") { const [client, server] = Object.values(new WebSocketPair()); server.accept(); server.addEventListener("message", (e) => server.send(`echo ${e.data}`)); return new Response(null, { status: 101, webSocket: client }); } return env.DIALER.getByName(url.searchParams.get("name") ?? "a").fetch(request); }, }; ```
With esbuild on PATH:
```sh celld dev --port 8787 & # or: npx wrangler@4.145.0 dev --port 8787 & curl -s 'localhost:8787/now?name=a' # dials inside the request curl -s 'localhost:8787/later?name=b' # dials after the response sleep 2 curl -s 'localhost:8787/?name=a' curl -s 'localhost:8787/?name=b' ```
## Expected (workerd) vs actual (celld)
workerd (wrangler 4.145.0, workerd 1.20260930.2):
``` ["+3ms open","+4ms message echo 1","+1004ms message echo 2"] ["+4ms open","+4ms message echo 1","+1006ms message echo 2"] ```
celld v0.6.1:
``` ["+1ms open","+1ms message echo 1","+1003ms message echo 2"] ["+1ms threw WebSocket upgrade failed: isolate stopped reading WebSocket"] ```
Other ways of dialing after the response, same object:
| `/later` dials from | celld v0.6.1 | workerd | |---|---|---| | a `setTimeout` callback the request set | throws, as above | open, echoes | | `ctx.waitUntil(promise.then(dial))` | opens, then at once `close 1006 socket is not registered` | open, echoes | | `.then` of a promise the request made, resolved by a later request | the `fetch` never settles | open, echoes | | the repro's continuation, but a plain `fetch` | 200 | 200 |
Dialing a server in another process fails the same way, so it is not the loopback to the same node.
Where it goes wrong, from a `console.log` in `__fetchWebSocketUpgrade` (`harness.js`): inside the request `__currentActorScope()` is `"Dialer:…"`; in the continuation it is `""`, as the event has ended. So the socket is opened as a stateless Worker's (`socket._polled`, `ws_track_request_socket`), whose life is a request's, not the object's.
Cloudflare documents the object-wide context: "In a Worker, each request has its own input/output (I/O) context." and "A Durable Object shares one I/O context across all concurrent requests to the same object." (https://developers.cloudflare.com/dynamic-workers/platform/limits/). And "TCP sockets and outbound WebSockets also prevent eviction." (https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/).
## Version
celld v0.6.1 (`f2bf648`): the x86_64 Linux release binary, and a source build of the tag. Linux x86_64, `celld dev`.
hmkreferenced this in Durable Object: an outbound call from a continuation of an ended request, woken by another event, never settles #258last month on GitHub