Dynamic facet WebSocket upgrades lose their target; restoring it exposes missing facet event routing
boliang-pinai opened ·
# Dynamic facet WebSocket upgrades lose their target; restoring it reveals missing facet event routing
Tested celld 0.5.0, commit `12d5b6333fe52717325addcfe1e99e9fd4f77bcd`, macOS arm64, local `celld dev`. No application framework or external storage is required. Complete minimal reproducer is included below.
## Reproduction
```sh celld dev . --host 127.0.0.1 --port 18975 --no-watch --logs # In another terminal, with the ws npm package available: node client.mjs ws://127.0.0.1:18975/ ```
A Root DO obtains a loaded Worker class with `getDurableObjectClass`, starts it with `ctx.facets.get`, and returns `facet.fetch(request)`. The facet accepts a WebSocketPair and echoes messages. Expected: a client connects, exchanges text/binary frames and closes normally.
## Two separate failures
1. On the unmodified release, `op_facet_fetch` uses `encode_http_response(response, false, ...)`, and `DurableObjectFacets.get().fetch` constructs a Response without `wsTarget`. The response loses its connection target. 2. Restoring the existing target field in both places is insufficient. With that patch compiled from the exact commit, the minimal client reports:
```json {"opened":true,"echoed":false,"closed":1006,"error":null} ```
The runtime logs:
```text celld runtime start failed for Echo:<facet-hash>: no Worker exports Durable Object class Echo WebSocket close dispatch failed ... RuntimeFailed scope=Echo:<facet-hash> ```
`main/websocket.rs::dispatch_ws_message` and `dispatch_ws_closed` route the synthetic facet scope through `app.websocket_request`; `Runtime::ws_message` expects an ordinary published cell. The facet instead lives in the loaded worker created by `prepare_loaded_facet`. Its scope is not an independently deployable cell.
## Fix requirements beyond preserving the field
The owner/root cell and the live loaded facet must both be represented in event routing. A complete fix must preserve the root cell's ownership and durability gates, account for socket residency, reject stale callbacks after `facets.abort`/worker disposal, and close or reconnect cleanly after a root moves/restarts. Simply invoking a facet callback directly bypasses these invariants. Hibernation and nested facets also need explicit coverage.
The local two-field patch is only diagnostic evidence and has **not passed acceptance**. Do not use it as a production fix.
Related but different: https://github.com/denoland/celld/issues/149 fixed target loss for ordinary cross-script service bindings; that does not provide event routing for dynamic facets.
### index.js
```js import { DurableObject } from 'cloudflare:workers'; const code = ` import { DurableObject } from 'cloudflare:workers'; export class Echo extends DurableObject { fetch() { const [client, server] = Object.values(new WebSocketPair()); server.accept(); server.addEventListener('message', event => server.send(event.data)); server.addEventListener('close', event => server.close(event.code, event.reason)); return new Response(null, { status: 101, webSocket: client }); } }`; export class Root extends DurableObject { fetch(request) { const worker = this.env.LOADER.get('echo', () => ({ mainModule: 'echo.js', modules: { 'echo.js': code } })); return this.ctx.facets.get('echo', () => ({ class: worker.getDurableObjectClass('Echo') })).fetch(request); } } export default { fetch(request, env) { return env.ROOT.getByName('root').fetch(request); } }; ```
### wrangler.json
```json { "name": "facet-websocket-repro", "main": "index.js", "compatibility_date": "2026-09-16", "worker_loaders": [{ "binding": "LOADER" }], "durable_objects": { "bindings": [{ "name": "ROOT", "class_name": "Root" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Root"] }] } ```
### client.mjs
```js import { WebSocket } from 'ws'; const socket = new WebSocket(process.argv[2] || 'ws://127.0.0.1:18975/'); const evidence = { opened: false, echoed: false, closed: null, error: null }; const timer = setTimeout(() => { socket.terminate(); console.log(JSON.stringify(evidence)); process.exit(evidence.echoed ? 0 : 1); }, 5000); socket.on('open', () => { evidence.opened = true; socket.send('hello'); }); socket.on('message', data => { evidence.echoed = data.toString() === 'hello'; socket.close(1000, 'done'); }); socket.on('error', error => { evidence.error = error.message; }); socket.on('close', code => { evidence.closed = code; clearTimeout(timer); console.log(JSON.stringify(evidence)); process.exit(evidence.echoed ? 0 : 1); }); ```
### diagnostic-only.patch
```diff diff --git a/crates/celld/js.rs b/crates/celld/js.rs index 353e457..0deacaa 100644 --- a/crates/celld/js.rs +++ b/crates/celld/js.rs @@ -10078,7 +10078,7 @@ fn op_facet_fetch( match receive.await { Ok(Ok(response)) => { body_guard.disarm(); - encode_http_response(response, false, &stream_service) + encode_http_response(response, true, &stream_service) } Ok(Err(error)) => Err(format!("{error}")), Err(_) => match driving.await { diff --git a/crates/celld/js/harness.js b/crates/celld/js/harness.js index 99470b8..7aa71b2 100644 --- a/crates/celld/js/harness.js +++ b/crates/celld/js/harness.js @@ -2151,6 +2151,10 @@ class DurableObjectFacets { return __wrapServiceResponse(new Response(responseBody, { status: response.status, headers: response.headers, + // Preserve the facet's native connection target across isolates. + // __wrapServiceResponse exposes a client socket without binding it + // until accept(), so callers can also return the upgrade directly. + __wsTarget: response.wsTarget, }), req.url); }); }; ```
popmechanicreferenced this in Prototype: root plus facets on celld — the generated app owns its entities, the verification host is the production host, a fork is a facet (map #998, the runtime half of #996) #1094last month on GitHub
popmechanicreferenced this in Root and module objects on celld: the server shape, the exam surface, the recorder, and the socket harness #29last month on GitHub
popmechanicreferenced this in chore(fleet): celld 0.6.1 on the fleet; facet sockets re-read on it #1492last month on GitHub
popmechanicreferenced 0ef622d1e244last month on GitHub
popmechanicreferenced this in review 2026-10-01: minor findings across today's PRs #1532last month on GitHub
dacuna-icreferenced this in Are WebSockets in Durable Object facets intended to be supported? #252last month on GitHub