denoland/celldPublic

self-hosted, distributed Durable Objects

Durable Object: an outbound call from a continuation of an ended request, woken by another event, never settles

HM

hmk opened ·

In a Durable Object, a request starts async work and returns. A later event (another request, or a message on a regular WebSocket) resolves a promise that work is waiting on. If the continuation then makes an outbound call (a Worker Loader entrypoint call, `fetch()`, a Durable Object stub call), the call never settles: no result, no error, nothing in the log at `RUST_LOG=debug`. Storage reads and writes from the same continuation work, and so does `ws.send()`. workerd runs every case.

The same continuation woken by its own `setTimeout` (no other event involved) works.

This is how Cap'n Web serves a WebSocket (`newWorkersWebSocketRpcResponse`): its read loop starts in the upgrade request and awaits a promise that each `message` event resolves, so every RPC method runs as such a continuation, and any method that makes an outbound call hangs. `ctx.waitUntil` and a timer set from there don't help, `ctx.blockConcurrencyWhile` throws `the cell event ended before it could acquire an input gate`, and the same call made directly in the `message` listener works.

Related: #200 (fixed timers in this shape in 0.5.0; outbound calls still fail), #254, #255.

Found and reduced by Claude (Opus 5.5) while upgrading our fleet from 0.2.1; the repro below was rerun by hand against both runtimes.

## Repro

<details><summary>wrangler.jsonc, index.js</summary>

```jsonc { "name": "woken-outbound", "main": "index.js", "compatibility_date": "2026-01-01", "worker_loaders": [{ "binding": "LOADER" }], "durable_objects": { "bindings": [{ "name": "CELL", "class_name": "Cell" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Cell"] }] } ```

```js import { DurableObject } from "cloudflare:workers";

const CHILD = `import { WorkerEntrypoint } from "cloudflare:workers"; export default class extends WorkerEntrypoint { add(n) { return n + 1; } }`;

export class Cell extends DurableObject { async op(how, origin) { if (how === "loader") { return this.env.LOADER.load({ compatibilityDate: "2026-01-01", mainModule: "index.js", modules: { "index.js": CHILD }, globalOutbound: null, }).getEntrypoint().add(1); } if (how === "fetch") return (await fetch(origin + "/plain")).text(); await this.ctx.storage.put("k", 1); return "stored"; } async fetch(request) { const url = new URL(request.url); const how = url.searchParams.get("how"); const settle = (p) => p.then((v) => { this.result = `OK ${v}`; }, (e) => { this.result = `threw ${e.message}`; }); if (url.pathname === "/self") { // woken by its own timer this.result = "pending"; settle(new Promise((r) => setTimeout(r, 200)).then(() => this.op(how, url.origin))); return new Response("started\n"); } if (url.pathname === "/start") { // woken by a later request this.result = "pending"; settle(new Promise((r) => { this.notify = r; }).then(() => this.op(how, url.origin))); return new Response("started\n"); } if (url.pathname === "/notify") { this.notify(); return new Response("notified\n"); } return new Response(`${this.result}\n`); } }

export default { fetch(request, env) { const url = new URL(request.url); if (url.pathname === "/plain") return new Response("plain ok"); return env.CELL.get(env.CELL.idFromName(url.searchParams.get("name"))).fetch(request); }, }; ```

</details>

```sh celld dev . --port 8787 --no-watch & # or: npx wrangler dev --port 8787 B=http://127.0.0.1:8787 for how in loader fetch storage; do curl -s "$B/self?how=$how&name=s$how" >/dev/null; sleep 2 echo "self-timer $how: $(curl -s "$B/result?name=s$how")" curl -s "$B/start?how=$how&name=w$how" >/dev/null; curl -s "$B/notify?name=w$how" >/dev/null; sleep 2 echo "woken-by-request $how: $(curl -s "$B/result?name=w$how")" done ```

## Expected (workerd) vs actual (celld v0.6.1)

| Continuation woken by | Call | celld v0.6.1 | workerd (wrangler 4.148.0) | |---|---|---|---| | its own `setTimeout` | Worker Loader | OK 2 | OK 2 | | its own `setTimeout` | `fetch()` | OK | OK | | a later request | Worker Loader | **pending forever** | OK 2 | | a later request | `fetch()` | **pending forever** | OK | | a later request | storage | OK | OK |

With `RUST_LOG=debug`, the working case logs `output gate resolved event="gate_write_timing"` after the call; the hanging case logs nothing after the loaded module registers.

## Versions

Same result in bucket mode (one node, MinIO) and under `celld dev`, macOS arm64 release binaries: 0.2.1 and 0.3.0 pass; 0.4.0, 0.4.1, 0.5.0, 0.5.1, 0.6.0 and 0.6.1 hang (on 0.4.x the call hangs even when made directly in a regular socket's `message` listener). First bad: 0.4.0, whose notes say "Strengthen the Durable Object output gate. It now applies one ordering rule to responses, outbound calls, Queue deliveries, and WebSocket sends."

## Workaround we use

Make the outbound call from a continuation the live `message` event owns: Cap'n Web methods queue their work, and a second `message` listener on the same socket runs the queue from a `setTimeout(…, 0)` it sets (and again when each queued job settles, for pipelined calls).