Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/hibernatable-websockets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@mcp-b/do-runtime": minor
---

Replace the fail-closed Durable Object WebSocket stubs with workerd-compatible hibernatable WebSockets.

`WebSocketPair`, `WebSocketRequestResponsePair`, all eight `DurableObjectState` WebSocket methods, tags, structured-clone attachments, auto-responses, event timeouts, close state, and `webSocketMessage`/`webSocketClose`/`webSocketError` dispatch now run through actor input and output gates. `installActorScope()` installs the three WebSocket globals alongside the existing actor-scoped primitives.

Embedders that evict live actors can mirror socket state through the new optional `ports.hibernation` callbacks and rehydrate it through `ActorContainerOptions.webSockets` before the next constructor runs. `container.quiescence()` exposes the non-blocking eviction signals, and `gateHooks` makes both gates observable.

This is a breaking replacement for the exported hibernation-unavailable error and the previous `never`-typed methods. Hosts should remove reconnect-only fallbacks; applications can use the Agents SDK and PartyServer hibernation defaults.
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ Open a second container over the same directory and `increment()` answers `3`: t

Two runnable browser hosts live in [`examples/`](examples/), each with its own README and Playwright e2e (`pnpm test:examples`):

- [`examples/extension/`](examples/extension/) — a Chrome MV3 compatibility harness: service worker → offscreen document (with corpse recovery) → worker hosting an Agents SDK `Counter` and local sub-agents. Proves persistent state, sibling and nested facet isolation, overlapping async work, abort/delete lifecycle, sub-agent scheduling across host recreation, exclusive host ownership, non-hibernating `AgentClient` WebSockets, state sync, callable and streaming RPC, SDK queues, stateless MCP, inbound email routing, the MV3 CSP story (`'wasm-unsafe-eval'`), and `chrome.alarms` recreation of an evicted host before durable alarm delivery.
- [`examples/extension/`](examples/extension/) — a Chrome MV3 compatibility harness: service worker → offscreen document (with corpse recovery) → worker hosting an Agents SDK `Counter` and local sub-agents. Proves persistent state, sibling and nested facet isolation, overlapping async work, abort/delete lifecycle, sub-agent scheduling across host recreation, exclusive host ownership, hibernating `AgentClient` WebSockets, state sync, callable and streaming RPC, SDK queues, stateless MCP, inbound email routing, the MV3 CSP story (`'wasm-unsafe-eval'`), and `chrome.alarms` recreation of an evicted host before durable alarm delivery.
- [`examples/vibe-platform/`](examples/vibe-platform/) — a self-contained vibe-coding page that authors both a front-end and an Agents SDK `Agent`, runs them in-tab with durable SQLite-backed state, and exports the unchanged sources as a Wrangler project that passes `wrangler deploy --dry-run`.

## Hosting an actor
Expand All @@ -175,6 +175,9 @@ The runtime owns semantics; the host owns placement and substrate. `createActorC
| `ports.facets` | A `FacetHost`: place a child container, abort it, copy or delete its storage. |
| `ports.timer` | `now()` and `afterDelay()`, captured below any installed actor scope. |
| `ports.fetch` | Optional global outbound. Absent means `fetch` refuses by name, as a Worker with `globalOutbound: null` does. |
| `ports.hibernation` | Optional mirror callbacks for accepted sockets, attachment bytes, auto-response changes, and closure. Omit it when the host never rebuilds a live socket placement. |
| `webSockets` | Socket references and mirrored tags/attachments to register before the new instance constructor runs. |
| `gateHooks` | Optional input/output gate instrumentation for an embedding host. |
| `facet` | Present when constructing a local child: its id, depth, and the root-owned `FacetTree`. |

The lifecycle:
Expand All @@ -185,6 +188,7 @@ The lifecycle:
4. Use `container.run(fn, signal?)` for events that are not method calls: a WebSocket frame, a host callback. Its signal likewise stops only a queued event, not one already running.
5. Reach the platform through `container.globals` (or install it with `installActorScope`). For a host-provided promise an actor must await, wrap it once in `container.awaitIo()`.
6. Watch `container.onBroken`; dispose the placement; recreate it on the next event over the same storage. A failed `blockConcurrencyWhile()` rejects its caller with `BrokenActorError` and breaks the placement with that same error.
7. Before evicting, inspect `container.quiescence()`. Mirror live sockets through `ports.hibernation`, then build the replacement with `webSockets`; do not reconnect or call `acceptWebSocket()` again.

For a standard Durable Object binding, call
`createDurableObjectNamespace(uniqueKey, channel)` and put the result in `env`
Expand Down Expand Up @@ -221,7 +225,25 @@ Construct one `AlarmScheduler` per namespace over a `SqlDatabase` of its own. It

On workerd every awaitable thing is an io-context primitive, so "resuming from an await re-enters with a fresh input lock" never needs saying. Here it does. A raw `setTimeout` resolves a promise the runtime does not own; the continuation resumes with an empty invocation stack and the next `ctx.storage` call throws `no input lock available in this context`. That is by design — the alternative is a continuation that silently writes outside the gate.

`container.globals` is the complete gated set, bound to that container: `setTimeout`/`clearTimeout`/`setInterval`/`clearInterval` capture the critical section when armed and re-enter when fired; `scheduler.wait()` and `scheduler.yield()` resume under the actor; `fetch()` waits for output locks and releases the input gate while in flight; `crypto` re-enters on async completion; accepted WebSocket frames enter through the captured context. Install it as the worker's globals (`installActorScope`) when one worker hosts one root, or hand it to application code explicitly when it must not.
`container.globals` is the complete gated set, bound to that container: `setTimeout`/`clearTimeout`/`setInterval`/`clearInterval` capture the critical section when armed and re-enter when fired; `scheduler.wait()` and `scheduler.yield()` resume under the actor; `fetch()` waits for output locks and releases the input gate while in flight; `crypto` re-enters on async completion; and `WebSocketPair` creates runtime-owned socket halves. Install it as the worker's globals (`installActorScope`) when one worker hosts one root, or hand it to application code explicitly when it must not.

### Hibernatable WebSockets

`ctx.acceptWebSocket(socket, tags)` enables class-method dispatch and the full
Workers state API: `getWebSockets`, `getTags`, attachments, auto-response pairs
and timestamps, and the hibernatable event timeout. The runtime works without a
hibernation port for hosts that keep a container alive.

An evicting host implements `ports.hibernation` as a mirror. It retains the same
raw socket reference plus copied tags and attachment bytes, drops the old
placement, and supplies that snapshot as `webSockets` on the replacement. The
registry is populated before the constructor, so SDKs can lazily rebuild their
connection wrappers without another upgrade or connect hook. Closed sockets are
removed before `webSocketClose` runs.

`container.quiescence()` reports armed timers, pending `waitUntil` work, input
lock state, and output-gate breakage without waiting. `drainWaitUntil()` is for
shutdown and intentionally never settles while a live interval remains armed.

Actor bundles can also install `doRuntimeAwaitTransform()` from `@mcp-b/do-runtime/vite`. A production build checks the final module graph and fails with transformed/total counts for any included module with an uncovered await; the development transform warns once per module if a transformed await reaches its fail-open path without an actor lock.

Expand All @@ -231,7 +253,6 @@ The browser cannot reproduce every workerd facility. Where it cannot, the runtim

| Area | Contract here |
| --- | --- |
| Hibernatable WebSockets | Unsupported; named methods throw. Use memory-only sockets and reconnect. |
| Cloudflare point-in-time recovery and read replication | Unsupported by local SQLite; named methods throw. Bookmarks are development counters, not recovery points. |
| Actor-class stub serialization | Throws; needs workerd's serializer and channel tokens. |
| Module-scope `waitUntil`, `cache`, `abortIsolate`, Workers RPC stub constructors | Named `cloudflare:workers` boundaries throw. |
Expand Down
106 changes: 105 additions & 1 deletion conformance/browser/actor.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ import {
type SqliteWasmHost,
} from "../../backends/sqlite-wasm";
import { Probe } from "../fixtures/probe";
import { HibernationMirror } from "../../examples/platform-shims/hibernation-mirror";
import {
installWebSocketUpgradeGlobals,
upgradeWebSocket,
webSocketUpgradeRequest,
type UpgradeWebSocket,
} from "../websocket-upgrade";
import type { ActorBoot, ActorRpc, SupervisorRpc } from "./protocol";
import { installPool, timer, UNIQUE_KEY } from "./substrate";

Expand Down Expand Up @@ -107,6 +114,56 @@ let placing: Promise<Live> | undefined;
/** The page. */
let peer: Session<SupervisorRpc> | undefined;

type SocketClose = { code: number; reason: string; wasClean: boolean };
type ClientRecord = {
socket: UpgradeWebSocket;
messages: (string | ArrayBuffer)[];
messageWaiters: ((message: string | ArrayBuffer) => void)[];
closes: SocketClose[];
closeWaiters: ((close: SocketClose) => void)[];
};

const hibernation = new HibernationMirror();
const clients = new Map<string, ClientRecord>();
let clientCounter = 0;

function registerClient(socket: UpgradeWebSocket): string {
const id = `socket-${clientCounter++}`;
const record: ClientRecord = {
socket,
messages: [],
messageWaiters: [],
closes: [],
closeWaiters: [],
};
socket.addEventListener("message", (event) => {
const data = (event as MessageEvent).data as string | ArrayBuffer;
const waiter = record.messageWaiters.shift();
if (waiter === undefined) record.messages.push(data);
else waiter(data);
});
socket.addEventListener("close", (event) => {
const closeEvent = event as CloseEvent;
const close = {
code: closeEvent.code,
reason: closeEvent.reason,
wasClean: closeEvent.wasClean,
};
const waiter = record.closeWaiters.shift();
if (waiter === undefined) record.closes.push(close);
else waiter(close);
});
clients.set(id, record);
socket.accept();
return id;
}

function client(id: string): ClientRecord {
const record = clients.get(id);
if (record === undefined) throw new Error(`Browser lane: no client socket ${id}.`);
return record;
}

// =======================================================================================
// The platform globals workerd has natively

Expand Down Expand Up @@ -173,6 +230,7 @@ function rootScope(op: string): ActorGlobalScope {
*/
function installRootScope(): void {
installActorScope(globalThis, () => rootScope("a root global"));
installWebSocketUpgradeGlobals();
}

// =======================================================================================
Expand Down Expand Up @@ -487,7 +545,7 @@ const facetScopes: Record<string, ActorScopeBindings> = {};
(globalThis as Record<string, unknown>)[FACET_SCOPE_GLOBAL] = facetScopes;
let facetScopeCounter = 0;

/** The seven names `installActorScope` writes, in the order the prologue destructures them. */
/** The actor globals the dynamic facet module binds to its own container. */
const FACET_SCOPE_NAMES = [
"scheduler",
"setTimeout",
Expand All @@ -496,6 +554,9 @@ const FACET_SCOPE_NAMES = [
"clearInterval",
"fetch",
"crypto",
"WebSocket",
"WebSocketPair",
"WebSocketRequestResponsePair",
] as const;

async function facetModule(className: string, gate: FacetGate): Promise<FacetClass> {
Expand Down Expand Up @@ -671,11 +732,13 @@ async function place(): Promise<Live> {
alarms: alarmOutlet(current.actorName),
facets,
timer,
hibernation,
fetch: async () => {
await timer.afterDelay(60);
return new Response("fetched");
},
},
webSockets: hibernation.snapshot(),
});

// ← `WorkerdApi::compileGlobals`'s `Global::WorkerLoader` arm. Filled in after construction
Expand Down Expand Up @@ -754,6 +817,47 @@ class RootTarget extends RpcTarget implements ActorRpc {
await placed();
}

async evict(): Promise<void> {
teardown();
await placed();
}

async connect(tags: string[]): Promise<{ id: string; readyState: number }> {
const response = (await (await placed()).entry.fetch(
webSocketUpgradeRequest("https://probe.invalid/socket", tags),
)) as Response;
const socket = upgradeWebSocket(response);
if (socket === undefined) throw new Error("Browser lane: probe fetch did not upgrade.");
const id = registerClient(socket);
return { id, readyState: socket.readyState };
}

socketSend(id: string, data: string | ArrayBuffer): Promise<void> {
client(id).socket.send(data);
return Promise.resolve();
}

socketClose(id: string, code?: number, reason?: string): Promise<void> {
client(id).socket.close(code, reason);
return Promise.resolve();
}

nextSocketMessage(id: string): Promise<string | ArrayBuffer> {
const record = client(id);
const message = record.messages.shift();
return message === undefined
? new Promise((resolve) => record.messageWaiters.push(resolve))
: Promise.resolve(message);
}

nextSocketClose(id: string): Promise<SocketClose> {
const record = client(id);
const close = record.closes.shift();
return close === undefined
? new Promise((resolve) => record.closeWaiters.push(resolve))
: Promise.resolve(close);
}

crash(): Promise<void> {
teardown();
return Promise.resolve();
Expand Down
56 changes: 55 additions & 1 deletion conformance/browser/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,13 @@ import type { AlarmResult } from "../../src/index";
import { RpcTarget } from "../../src/api/cloudflare-workers";
import type { ActorBoot, ActorRpc, AlarmsBoot, AlarmsRpc, SupervisorRpc } from "./protocol";
import { poolName, reportWorkerErrors } from "./protocol";
import type { Capability, ConformanceHost, ProbeActor } from "../host";
import type {
Capability,
ConformanceHost,
LaneClientSocket,
LaneSocketMessage,
ProbeActor,
} from "../host";

type Session<T> = ReturnType<typeof newRpcSession<T>>;

Expand All @@ -91,6 +97,44 @@ type Placed = {
readonly rpc: Session<ActorRpc>;
};

class BrowserClientSocket implements LaneClientSocket {
#readyState: number;

constructor(
readonly rpc: Session<ActorRpc>,
readonly id: string,
readyState: number,
) {
this.#readyState = readyState;
}

get readyState(): number {
return this.#readyState;
}

async send(data: string | ArrayBuffer | ArrayBufferView): Promise<void> {
const message = ArrayBuffer.isView(data)
? new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice().buffer
: data;
await this.rpc.socketSend(this.id, message);
}

async close(code?: number, reason?: string): Promise<void> {
this.#readyState = WebSocket.CLOSING;
await this.rpc.socketClose(this.id, code, reason);
}

nextMessage(): Promise<LaneSocketMessage> {
return this.rpc.nextSocketMessage(this.id);
}

async nextClose(): Promise<{ code: number; reason: string; wasClean: boolean }> {
const close = await this.rpc.nextSocketClose(this.id);
this.#readyState = WebSocket.CLOSED;
return close;
}
}

const live = new Map<string, Placed>();

/**
Expand Down Expand Up @@ -213,6 +257,16 @@ export const host: ConformanceHost = {
return actor(previous.name);
},

connect: async (target, tags = []) => {
const rpc = place(target.name).rpc;
const socket = await rpc.connect([...tags]);
return new BrowserClientSocket(rpc, socket.id, socket.readyState);
},

evict: async (target) => {
await place(target.name).rpc.evict();
},

/**
* Drop the container without letting it flush: the files are all that
* survives, which is the same thing the node lane's `crash` means. The worker
Expand Down
9 changes: 9 additions & 0 deletions conformance/browser/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ export interface ActorRpc {
call(method: string, args: unknown[]): Promise<unknown>;
/** Same identity, fresh instance: drop the container, reopen the same files. */
respawn(): Promise<void>;
/** Same identity and transport: rebuild with the mirrored hibernation state. */
evict(): Promise<void>;
connect(tags: string[]): Promise<{ id: string; readyState: number }>;
socketSend(id: string, data: string | ArrayBuffer): Promise<void>;
socketClose(id: string, code?: number, reason?: string): Promise<void>;
nextSocketMessage(id: string): Promise<string | ArrayBuffer>;
nextSocketClose(
id: string,
): Promise<{ code: number; reason: string; wasClean: boolean }>;
/** ← `ConformanceHost.crash`: drop the container and do NOT replace it. */
crash(): Promise<void>;
deliverAlarm(scheduledTime: number, retryCount: number): Promise<AlarmResult>;
Expand Down
Loading