v0.4.1: detached timer stalls after DO webSocketMessage returns
bohdanbirdie opened 路
On **celld v0.4.1**, a 20 ms timer started in a hibernatable DO's `webSocketMessage` handler does not complete within 300 ms after the handler returns. The identical worker passes under local workerd. The reproduction uses no application libraries, database writes, or external services.
## Expected / actual
The timer should complete without `waitUntil`, matching workerd and the [documented DO lifetime behavior](https://developers.cloudflare.com/durable-objects/api/state/#waituntil). Each case uses a fresh object ID. The callback sets an in-memory flag before sending `completed`; the client checks both after 300 ms.
| Scheduling | celld v0.4.1 | workerd 1.20251008.0 | | --- | --- | --- | | Timer; handler returns without awaiting | Only `received`; flag `false` | `completed`; flag `true` | | Handler awaits timer | Pass | Pass | | Timer promise passed to `ctx.waitUntil` | Pass | Pass | | `queueMicrotask` instead of timer | Pass | Pass |
Environment: macOS ARM64, Node 24.20.0, esbuild 0.28.0, native `celld dev` with local SQLite object storage. Control: Miniflare 4.20251008.0, same worker and compatibility date `2025-05-08`.
## Reproduction
Save these three files in an empty directory. With celld v0.4.1 and esbuild on PATH, run `celld dev . --host 127.0.0.1 --port 19876 --no-watch`. After it reports ready, run `node probe.mjs` in another terminal. The client prints the four results above. Stop celld with Ctrl-C afterward.
<details> <summary>worker.js</summary>
```js import { DurableObject } from 'cloudflare:workers'
export class TimerProbe extends DurableObject { completed = false
fetch(request) { if (new URL(request.url).pathname === '/status') { return Response.json({ completed: this.completed }) } const pair = new WebSocketPair() this.ctx.acceptWebSocket(pair[1]) return new Response(null, { status: 101, webSocket: pair[0] }) }
async webSocketMessage(ws, mode) { ws.send('received') const complete = () => { this.completed = true ws.send('completed') } if (mode === 'microtask') { queueMicrotask(complete) return } const work = new Promise((resolve) => setTimeout(resolve, 20)).then(complete) if (mode === 'awaited') await work else if (mode === 'waitUntil') this.ctx.waitUntil(work) // 'detached' intentionally returns with its timer still pending. }
webSocketClose(ws, code) { ws.close(code === 1005 ? 1000 : code) } }
export default { fetch(request, env) { const id = env.PROBE.idFromName(new URL(request.url).searchParams.get('id')) return env.PROBE.get(id).fetch(request) }, } ```
</details>
<details> <summary>wrangler.json</summary>
```json { "name": "celld-timer-repro", "main": "./worker.js", "compatibility_date": "2025-05-08", "durable_objects": { "bindings": [{ "name": "PROBE", "class_name": "TimerProbe" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["TimerProbe"] }] } ```
</details>
<details> <summary>probe.mjs (Node 24, no npm dependencies)</summary>
```js import { once } from 'node:events' import { setTimeout as delay } from 'node:timers/promises'
export async function runTimerCases(origin) { const results = [] for (const mode of ['detached', 'awaited', 'waitUntil', 'microtask']) { const id = `${mode}-${crypto.randomUUID()}` const ws = new WebSocket(`${origin.replace('http:', 'ws:')}/?id=${id}`) const messages = [] ws.addEventListener('message', (event) => messages.push(event.data)) try { await once(ws, 'open', { signal: AbortSignal.timeout(3000) }) ws.send(mode) await delay(300) const status = await fetch(`${origin}/status?id=${id}`, { signal: AbortSignal.timeout(3000) }).then((r) => r.json(), ) results.push({ mode, messages, completed: status.completed }) } finally { ws.close() } } console.log('TIMER RESULTS:', JSON.stringify(results)) return results }
await runTimerCases(process.argv[2] ?? 'http://127.0.0.1:19876') ```
</details>
Related: #141 describes a similar detached-work / `waitUntil` difference for fetches and was closed with a fix announced for v0.2.0. This is a timer/DO WebSocket case on v0.4.1; I have not established a regression of that fix.
bartlomiejuassigned bartlomieju2 months ago on GitHub
- BA
Thanks for the report. I just fixed the problem, it will be released in the next version
sandros94referenced this in v0.4.1: cron `scheduled` handler: `waitUntil` work is not drained after the handler settles #1912 months ago on GitHub
bohdanbirdiereferenced this in Document self-hosting the Cloudflare sync backend on celld #1651last month on GitHub