diff --git a/.changeset/hibernatable-websockets.md b/.changeset/hibernatable-websockets.md new file mode 100644 index 0000000..e70f014 --- /dev/null +++ b/.changeset/hibernatable-websockets.md @@ -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. diff --git a/README.md b/README.md index 0b741ce..0fb9de3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -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` @@ -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. @@ -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. | diff --git a/conformance/browser/actor.worker.ts b/conformance/browser/actor.worker.ts index b69745c..6034cd4 100644 --- a/conformance/browser/actor.worker.ts +++ b/conformance/browser/actor.worker.ts @@ -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"; @@ -107,6 +114,56 @@ let placing: Promise | undefined; /** The page. */ let peer: Session | 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(); +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 @@ -173,6 +230,7 @@ function rootScope(op: string): ActorGlobalScope { */ function installRootScope(): void { installActorScope(globalThis, () => rootScope("a root global")); + installWebSocketUpgradeGlobals(); } // ======================================================================================= @@ -487,7 +545,7 @@ const facetScopes: Record = {}; (globalThis as Record)[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", @@ -496,6 +554,9 @@ const FACET_SCOPE_NAMES = [ "clearInterval", "fetch", "crypto", + "WebSocket", + "WebSocketPair", + "WebSocketRequestResponsePair", ] as const; async function facetModule(className: string, gate: FacetGate): Promise { @@ -671,11 +732,13 @@ async function place(): Promise { 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 @@ -754,6 +817,47 @@ class RootTarget extends RpcTarget implements ActorRpc { await placed(); } + async evict(): Promise { + 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 { + client(id).socket.send(data); + return Promise.resolve(); + } + + socketClose(id: string, code?: number, reason?: string): Promise { + client(id).socket.close(code, reason); + return Promise.resolve(); + } + + nextSocketMessage(id: string): Promise { + 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 { + const record = client(id); + const close = record.closes.shift(); + return close === undefined + ? new Promise((resolve) => record.closeWaiters.push(resolve)) + : Promise.resolve(close); + } + crash(): Promise { teardown(); return Promise.resolve(); diff --git a/conformance/browser/host.ts b/conformance/browser/host.ts index cd8c72a..7b03c3f 100644 --- a/conformance/browser/host.ts +++ b/conformance/browser/host.ts @@ -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 = ReturnType>; @@ -91,6 +97,44 @@ type Placed = { readonly rpc: Session; }; +class BrowserClientSocket implements LaneClientSocket { + #readyState: number; + + constructor( + readonly rpc: Session, + readonly id: string, + readyState: number, + ) { + this.#readyState = readyState; + } + + get readyState(): number { + return this.#readyState; + } + + async send(data: string | ArrayBuffer | ArrayBufferView): Promise { + 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 { + this.#readyState = WebSocket.CLOSING; + await this.rpc.socketClose(this.id, code, reason); + } + + nextMessage(): Promise { + 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(); /** @@ -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 diff --git a/conformance/browser/protocol.ts b/conformance/browser/protocol.ts index 8c2388a..3a7d6d3 100644 --- a/conformance/browser/protocol.ts +++ b/conformance/browser/protocol.ts @@ -107,6 +107,15 @@ export interface ActorRpc { call(method: string, args: unknown[]): Promise; /** Same identity, fresh instance: drop the container, reopen the same files. */ respawn(): Promise; + /** Same identity and transport: rebuild with the mirrored hibernation state. */ + evict(): Promise; + connect(tags: string[]): Promise<{ id: string; readyState: number }>; + socketSend(id: string, data: string | ArrayBuffer): Promise; + socketClose(id: string, code?: number, reason?: string): Promise; + nextSocketMessage(id: string): Promise; + nextSocketClose( + id: string, + ): Promise<{ code: number; reason: string; wasClean: boolean }>; /** ← `ConformanceHost.crash`: drop the container and do NOT replace it. */ crash(): Promise; deliverAlarm(scheduledTime: number, retryCount: number): Promise; diff --git a/conformance/fixtures/probe.ts b/conformance/fixtures/probe.ts index eb43da7..44f9da0 100644 --- a/conformance/fixtures/probe.ts +++ b/conformance/fixtures/probe.ts @@ -14,11 +14,30 @@ import { DurableObject } from "cloudflare:workers"; +type CapturedError = { name: string; message: string }; + +function captureError(action: () => unknown): CapturedError | null { + try { + action(); + return null; + } catch (error) { + return error instanceof Error + ? { name: error.name, message: error.message } + : { name: typeof error, message: String(error) }; + } +} + +function socketPair(): [WebSocket, WebSocket] { + const pair = new WebSocketPair(); + return [pair[0], pair[1]]; +} + /** The facet implementation, loaded through the Worker Loader binding. */ const CHILD_SOURCE = ` import { DurableObject } from "cloudflare:workers"; export class Child extends DurableObject { local = 0; + clients = []; async bump() { const n = ((await this.ctx.storage.get("n")) ?? 0) + 1; await this.ctx.storage.put("n", n); @@ -36,6 +55,14 @@ export class Child extends DurableObject { return await g.localBump(); } async localBump() { return ++this.local; } + openSocket() { + const pair = new WebSocketPair(); + this.ctx.acceptWebSocket(pair[1], ["facet"]); + pair[0].accept(); + this.clients.push(pair[0]); + return this.ctx.getWebSockets().length; + } + socketCount() { return this.ctx.getWebSockets().length; } /** * The facet breaks ITSELF — the one way a running facet becomes broken without * its parent asking for it, and therefore the only shape in which "does a @@ -85,6 +112,20 @@ export default { fetch() { return new Response("child"); } }; export class Probe extends DurableObject> { marker = "init"; trace: string[] = []; + #clients = new Map(); + #servers = new Map(); + #clientMessages = new Map(); + #clientCloses = new Map< + string, + { code: number; reason: string; wasClean: boolean }[] + >(); + #handlerEvents: Record[] = []; + #handlerTrace: string[] = []; + #handlerTimes: { event: string; at: number }[] = []; + #listenerMessages = 0; + #latePair: [WebSocket, WebSocket] | undefined; + #capacityClients: WebSocket[] = []; + #throwNextMessage = false; #child(slot = "c", className = "Child"): Record Promise> { const loader = (this.env as { LOADER: WorkerLoader }).LOADER; @@ -1039,6 +1080,555 @@ export class Probe extends DurableObject> { return await this.ctx.storage.getAlarm(); } + // -- hibernatable WebSockets ------------------------------------------------ + + async fetch(request: Request): Promise { + if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") { + return new Response("probe"); + } + + const [client, server] = socketPair(); + const tags = new URL(request.url).searchParams.getAll("tag"); + this.ctx.acceptWebSocket(server, tags); + WebSocket.prototype.serializeAttachment.call(server, { + id: tags[0] ?? "external", + tags, + marker: "attachment-survived", + }); + const connects = (((await this.ctx.storage.get("wsConnects")) as number | undefined) ?? 0) + 1; + await this.ctx.storage.put("wsConnects", connects); + return new Response(null, { status: 101, webSocket: client }); + } + + acceptanceSemantics(): Record { + const [, twice] = socketPair(); + this.ctx.acceptWebSocket(twice); + const doubleHibernation = captureError(() => this.ctx.acceptWebSocket(twice)); + + const [, classicFirst] = socketPair(); + classicFirst.accept(); + const classicThenHibernation = captureError(() => this.ctx.acceptWebSocket(classicFirst)); + + const [, hibernationFirst] = socketPair(); + this.ctx.acceptWebSocket(hibernationFirst); + const hibernationThenClassic = captureError(() => hibernationFirst.accept()); + + const [client, server] = socketPair(); + this.ctx.acceptWebSocket(client, ["client-half"]); + server.accept(); + + const [usedPeer, usedServer] = socketPair(); + usedPeer.accept(); + const usedPair = captureError(() => this.ctx.acceptWebSocket(usedServer)); + + return { + doubleHibernation, + classicThenHibernation, + hibernationThenClassic, + clientHalfAccepted: this.ctx.getTags(client), + usedPair, + }; + } + + async acceptAfterAwait(): Promise { + const [client, server] = socketPair(); + await Promise.resolve(); + this.ctx.acceptWebSocket(server, ["after-await"]); + client.accept(); + this.#clients.set("after-await", client); + return this.ctx.getTags(server); + } + + stashSocketForLaterEvent(): void { + this.#latePair = socketPair(); + } + + acceptSocketFromLaterEvent(): CapturedError | null { + const pair = this.#latePair; + if (pair === undefined) throw new Error("No late pair was stashed."); + const error = captureError(() => this.ctx.acceptWebSocket(pair[1])); + if (error === null) pair[0].accept(); + return error; + } + + tagSemantics(): Record { + const make = (tags: unknown): WebSocket => { + const [client, server] = socketPair(); + this.ctx.acceptWebSocket(server, tags as string[]); + client.accept(); + this.#clients.set(`tags-${this.#clients.size}`, client); + return server; + }; + const normalized = make(["", 123, null, { a: 1 }, "dup", "dup"]); + const tooMany = captureError(() => make(Array.from({ length: 11 }, (_, i) => `${i}`))); + const longTag = "x".repeat(257); + const tooLong = captureError(() => make([longTag])); + const nonArray = captureError(() => make("tag")); + return { + normalized: this.ctx.getTags(normalized), + tooMany, + tooLong, + nonArray, + }; + } + + orderingSemantics(): Record { + const accept = (id: string, tags: string[]) => { + const [client, server] = socketPair(); + Object.assign(server, { probeId: id }); + this.ctx.acceptWebSocket(server, tags); + client.accept(); + this.#clients.set(`order-${id}`, client); + return server; + }; + const first = accept("first", ["shared", "ALPHA"]); + const second = accept("second", ["shared"]); + const third = accept("third", []); + const ids = (sockets: WebSocket[]) => + sockets.map((socket) => (socket as WebSocket & { probeId: string }).probeId); + const a = this.ctx.getWebSockets(); + const b = this.ctx.getWebSockets(); + return { + all: ids(a), + shared: ids(this.ctx.getWebSockets("shared")), + alpha: ids(this.ctx.getWebSockets("ALPHA")), + lower: ids(this.ctx.getWebSockets("alpha")), + empty: ids(this.ctx.getWebSockets("")), + nonString: ids(this.ctx.getWebSockets(1 as never)), + freshArray: a !== b, + sameObjects: a.includes(first) && a.includes(second) && a.includes(third), + }; + } + + socketCapacity(): CapturedError | null { + for (let i = 0; i < 32_768; i++) { + const [client, server] = socketPair(); + this.ctx.acceptWebSocket(server); + client.accept(); + this.#capacityClients.push(client); + } + const [, overflow] = socketPair(); + return captureError(() => this.ctx.acceptWebSocket(overflow)); + } + + attachmentSemantics(): Record { + const [client, socket] = socketPair(); + const attachment = { nested: { value: 1 } }; + socket.serializeAttachment(attachment); + attachment.nested.value = 2; + const first = socket.deserializeAttachment() as { nested: { value: number } }; + first.nested.value = 3; + const second = socket.deserializeAttachment() as { nested: { value: number } }; + + const [, neverSerialized] = socketPair(); + socket.serializeAttachment(undefined); + const explicitUndefined = socket.deserializeAttachment(); + const zeroArgument = captureError(() => + Reflect.apply(WebSocket.prototype.serializeAttachment, socket, []), + ); + + const [, rich] = socketPair(); + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + rich.serializeAttachment({ + map: new Map([["k", 1]]), + date: new Date(0), + bigint: 1n, + bytes: new Uint8Array([1, 2]), + cyclic, + }); + const richResult = rich.deserializeAttachment() as Record; + + const [, invalid] = socketPair(); + const functionError = captureError(() => invalid.serializeAttachment(function foo() {})); + const symbolError = captureError(() => invalid.serializeAttachment(Symbol("s"))); + + const [, size] = socketPair(); + const pass = captureError(() => size.serializeAttachment("x".repeat(16_379))); + const fail = captureError(() => size.serializeAttachment("x".repeat(16_380))); + + const [classicClient, classic] = socketPair(); + classic.accept(); + classic.serializeAttachment("classic"); + classicClient.serializeAttachment("client"); + classicClient.accept(); + classicClient.close(1000, "done"); + classicClient.serializeAttachment("closed"); + + client.accept(); + return { + snapshot: second.nested.value, + freshClone: first !== second && first.nested !== second.nested, + neverSerialized: neverSerialized.deserializeAttachment(), + explicitUndefined: typeof explicitUndefined, + zeroArgument, + rich: { + map: richResult.map instanceof Map, + date: richResult.date instanceof Date, + bigint: typeof richResult.bigint, + bytes: richResult.bytes instanceof Uint8Array, + cyclic: + (richResult.cyclic as { self?: unknown }).self === (richResult.cyclic as { self?: unknown }), + }, + functionError, + symbolError, + sizePass: pass, + sizeFail: fail, + classic: classic.deserializeAttachment(), + client: classicClient.deserializeAttachment(), + }; + } + + openSelfSocket(id: string, tags: string[] = []): void { + const [client, server] = socketPair(); + server.addEventListener("message", () => { + this.#listenerMessages += 1; + }); + this.ctx.acceptWebSocket(server, tags); + WebSocket.prototype.serializeAttachment.call(server, { id, tags }); + client.accept(); + const messages: (string | ArrayBuffer)[] = []; + const closes: { code: number; reason: string; wasClean: boolean }[] = []; + client.addEventListener("message", (event) => { + messages.push(event.data as string | ArrayBuffer); + }); + client.addEventListener("close", (event) => { + closes.push({ code: event.code, reason: event.reason, wasClean: event.wasClean }); + }); + this.#clients.set(id, client); + this.#servers.set(id, server); + this.#clientMessages.set(id, messages); + this.#clientCloses.set(id, closes); + } + + sendSelf(id: string, message: string): void { + this.#requireClient(id).send(message); + } + + sendSelfBinary(id: string, bytes: number[]): void { + this.#requireClient(id).send(new Uint8Array(bytes)); + } + + closeSelfClient(id: string, code: number, reason: string): void { + this.#requireClient(id).close(code, reason); + } + + closeSelfServer(id: string, code: number, reason: string): void { + this.#requireServer(id).close(code, reason); + } + + throwOnNextSocketMessage(): void { + this.#throwNextMessage = true; + } + + removeSocketMessageHandler(): void { + Object.defineProperty(this, "webSocketMessage", { configurable: true, value: undefined }); + } + + restoreSocketMessageHandler(): void { + Reflect.deleteProperty(this, "webSocketMessage"); + } + + socketJournal(): Record { + return { + events: this.#handlerEvents, + trace: this.#handlerTrace, + times: this.#handlerTimes, + listenerMessages: this.#listenerMessages, + clients: Object.fromEntries( + [...this.#clientMessages].map(([id, messages]) => [ + id, + messages.map((message) => + message instanceof ArrayBuffer ? [...new Uint8Array(message)] : message, + ), + ]), + ), + closes: Object.fromEntries(this.#clientCloses), + listed: this.ctx.getWebSockets().map((socket) => this.#socketId(socket)), + }; + } + + sendAfterOwnClose(id: string): CapturedError | null { + const socket = this.#requireServer(id); + socket.close(4002, "server out"); + return captureError(() => socket.send("too late")); + } + + closeValidation(): Record { + const attempt = (code: number, reason?: string): CapturedError | null => { + const [client, server] = socketPair(); + this.ctx.acceptWebSocket(server); + client.accept(); + return captureError(() => server.close(code, reason)); + }; + return { + code999: attempt(999), + code1005: attempt(1005), + code1006: attempt(1006), + code5000: attempt(5000), + longReason: attempt(1000, "é".repeat(62)), + code1000: attempt(1000), + code3000: attempt(3000), + code4999: attempt(4999), + }; + } + + readyStateConstants(): Record { + const [client, server] = socketPair(); + const ctor = WebSocket as typeof WebSocket & Record; + const proto = WebSocket.prototype as WebSocket & Record; + return { + fresh: [client.readyState, server.readyState], + constructorValues: [ + ctor.READY_STATE_CONNECTING, + ctor.READY_STATE_OPEN, + ctor.READY_STATE_CLOSING, + ctor.READY_STATE_CLOSED, + ctor.CONNECTING, + ctor.OPEN, + ctor.CLOSING, + ctor.CLOSED, + ], + prototype: [ + proto.READY_STATE_CONNECTING, + proto.READY_STATE_OPEN, + proto.READY_STATE_CLOSING, + proto.READY_STATE_CLOSED, + proto.CONNECTING, + proto.OPEN, + proto.CLOSING, + proto.CLOSED, + ], + }; + } + + setAutoResponse(request: string, response: string): void { + this.ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(request, response)); + } + + clearAutoResponse(): void { + this.ctx.setWebSocketAutoResponse(); + } + + autoResponseSemantics(id: string): Record { + const first = this.ctx.getWebSocketAutoResponse(); + const second = this.ctx.getWebSocketAutoResponse(); + return { + value: first === null ? null : { request: first.request, response: first.response }, + fresh: first !== second, + timestamp: this.ctx.getWebSocketAutoResponseTimestamp(this.#requireServer(id))?.getTime() ?? null, + unacceptedTimestamp: this.ctx.getWebSocketAutoResponseTimestamp(socketPair()[1]), + badTimestamp: captureError(() => + this.ctx.getWebSocketAutoResponseTimestamp({} as WebSocket), + ), + nullSetter: captureError(() => this.ctx.setWebSocketAutoResponse(null as never)), + }; + } + + autoResponseLimits(): Record { + return { + request: captureError(() => { + this.ctx.setWebSocketAutoResponse( + new WebSocketRequestResponsePair("x".repeat(2_049), "response"), + ); + }), + response: captureError(() => { + this.ctx.setWebSocketAutoResponse( + new WebSocketRequestResponsePair("request", "x".repeat(2_049)), + ); + }), + }; + } + + timeoutSemantics(): Record { + const initial = this.ctx.getHibernatableWebSocketEventTimeout(); + this.ctx.setHibernatableWebSocketEventTimeout(1_000); + const thousand = this.ctx.getHibernatableWebSocketEventTimeout(); + this.ctx.setHibernatableWebSocketEventTimeout(1.9); + const truncated = this.ctx.getHibernatableWebSocketEventTimeout(); + this.ctx.setHibernatableWebSocketEventTimeout("42" as never); + const coerced = this.ctx.getHibernatableWebSocketEventTimeout(); + const negative = captureError(() => this.ctx.setHibernatableWebSocketEventTimeout(-1)); + const outOfRange = captureError(() => + this.ctx.setHibernatableWebSocketEventTimeout(2 ** 32), + ); + const sevenDays = captureError(() => + this.ctx.setHibernatableWebSocketEventTimeout(604_800_001), + ); + const nan = captureError(() => this.ctx.setHibernatableWebSocketEventTimeout(Number.NaN)); + this.ctx.setHibernatableWebSocketEventTimeout(0); + return { + initial, + thousand, + truncated, + coerced, + negative, + outOfRange, + sevenDays, + nan, + cleared: this.ctx.getHibernatableWebSocketEventTimeout(), + }; + } + + pairClassSemantics(): Record { + const pair = new WebSocketRequestResponsePair(123 as never, null as never); + const requestDescriptor = Object.getOwnPropertyDescriptor( + WebSocketRequestResponsePair.prototype, + "request", + ); + return { + values: [pair.request, pair.response], + json: JSON.stringify(pair), + hasSetter: requestDescriptor?.set !== undefined, + withoutNew: captureError(() => + Reflect.apply(WebSocketRequestResponsePair as unknown as () => unknown, undefined, ["a", "b"]), + ), + badCoercion: captureError(() => + new WebSocketRequestResponsePair( + { + toString(): never { + throw new Error("coercion failed"); + }, + } as never, + "b", + ), + ), + }; + } + + getTagsSemantics(): Record { + const [, unaccepted] = socketPair(); + const [, classic] = socketPair(); + classic.accept(); + const [client, hibernatable] = socketPair(); + this.ctx.acceptWebSocket(hibernatable); + client.accept(); + const first = this.ctx.getTags(hibernatable); + const second = this.ctx.getTags(hibernatable); + return { + unaccepted: captureError(() => this.ctx.getTags(unaccepted)), + classic: captureError(() => this.ctx.getTags(classic)), + tags: first, + fresh: first !== second, + }; + } + + async facetSocketIsolation(): Promise { + const [client, server] = socketPair(); + this.ctx.acceptWebSocket(server, ["root"]); + client.accept(); + this.#clients.set("root-facet-check", client); + const child = this.#child("socket-isolation"); + const before = (await child.socketCount()) as number; + const childCount = (await child.openSocket()) as number; + return [this.ctx.getWebSockets().length, before, childCount]; + } + + setHibernationMarker(value: string): void { + this.marker = value; + } + + async readExternalObservation(): Promise | null> { + return ( + ((await this.ctx.storage.get("externalObservation")) as Record | undefined) ?? + null + ); + } + + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { + if (this.#throwNextMessage) { + this.#throwNextMessage = false; + throw new Error("conformance: socket handler threw"); + } + + const id = this.#socketId(ws); + const description = + typeof message === "string" + ? { kind: "string", value: message } + : { kind: "ArrayBuffer", value: [...new Uint8Array(message)] }; + this.#handlerEvents.push({ id, message: description }); + + if (typeof message === "string" && message.startsWith("slow:")) { + this.#handlerTrace.push(`start:${message}`); + this.#handlerTimes.push({ event: `start:${message}`, at: Date.now() }); + await scheduler.wait(200); + this.#handlerTrace.push(`end:${message}`); + this.#handlerTimes.push({ event: `end:${message}`, at: Date.now() }); + return; + } + if (typeof message === "string" && message.startsWith("block:")) { + this.#handlerTrace.push(`start:${message}`); + this.#handlerTimes.push({ event: `start:${message}`, at: Date.now() }); + await this.ctx.blockConcurrencyWhile(async () => { + await scheduler.wait(200); + }); + this.#handlerTrace.push(`end:${message}`); + this.#handlerTimes.push({ event: `end:${message}`, at: Date.now() }); + return; + } + if (message === "echo") ws.send("echoed"); + + this.ctx.storage.sql.exec( + "CREATE TABLE IF NOT EXISTS websocket_probe (id TEXT PRIMARY KEY, seen INTEGER)", + ); + this.ctx.storage.sql.exec( + "INSERT OR REPLACE INTO websocket_probe (id, seen) VALUES (?, ?)", + id, + 1, + ); + await this.ctx.storage.put("externalObservation", { + id, + message: description, + attachment: WebSocket.prototype.deserializeAttachment.call(ws), + tags: this.ctx.getTags(ws), + listed: this.ctx.getWebSockets().includes(ws), + actorName: this.ctx.id.name, + marker: this.marker, + connects: ((await this.ctx.storage.get("wsConnects")) as number | undefined) ?? 0, + }); + } + + webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): void { + const event: Record = { + id: this.#socketId(ws), + close: { code, reason, wasClean }, + readyState: ws.readyState, + listedDuringHandler: this.ctx.getWebSockets().includes(ws), + }; + event.sendAfterPeerClose = captureError(() => ws.send("after-peer-close")); + event.reciprocalClose = captureError(() => ws.close(code, reason)); + this.#handlerEvents.push(event); + } + + webSocketError(ws: WebSocket, error: unknown): void { + this.#handlerEvents.push({ + id: this.#socketId(ws), + error: error instanceof Error ? error.message : String(error), + readyState: ws.readyState, + }); + } + + #socketId(socket: WebSocket): string { + const attachment = WebSocket.prototype.deserializeAttachment.call(socket) as + | { id?: unknown } + | null; + return typeof attachment?.id === "string" + ? attachment.id + : ([...this.#servers].find(([, candidate]) => candidate === socket)?.[0] ?? "unknown"); + } + + #requireClient(id: string): WebSocket { + const socket = this.#clients.get(id); + if (socket === undefined) throw new Error(`No client socket ${id}.`); + return socket; + } + + #requireServer(id: string): WebSocket { + const socket = this.#servers.get(id); + if (socket === undefined) throw new Error(`No server socket ${id}.`); + return socket; + } + override async alarm(info?: AlarmInvocationInfo): Promise { const failures = (await this.ctx.storage.get("alarmFailures")) as number | undefined; if (failures !== undefined) { diff --git a/conformance/host.ts b/conformance/host.ts index 33e1a5c..1b8f782 100644 --- a/conformance/host.ts +++ b/conformance/host.ts @@ -17,8 +17,6 @@ export type Capability = | "fake-time" /** Kill without cleanup: worker.terminate() or dropping the container. */ | "real-crash" - /** Substrate boundary: no Chrome equivalent lifecycle. */ - | "hibernation" /** Substrate boundary: sqlite-wasm lacks the storage capability. */ | "bookmarks"; @@ -32,12 +30,26 @@ export interface ProbeActor { post(method: string, ...args: readonly unknown[]): { settled: Promise }; } +export type LaneSocketMessage = string | ArrayBuffer; + +export interface LaneClientSocket { + readonly readyState: number; + send(data: string | ArrayBuffer | ArrayBufferView): Promise; + close(code?: number, reason?: string): Promise; + nextMessage(): Promise; + nextClose(): Promise<{ code: number; reason: string; wasClean: boolean }>; +} + export interface ConformanceHost { readonly lane: LaneName; readonly capabilities: ReadonlySet; spawn(name?: string): Promise; /** Same identity, fresh instance. Durable state must survive. */ respawn(actor: ProbeActor): Promise; + /** Open a WebSocket through the actor's fetch handler. */ + connect(actor: ProbeActor, tags?: readonly string[]): Promise; + /** Rebuild the actor while preserving its hibernatable sockets. */ + evict(actor: ProbeActor): Promise; /** Only where "real-crash". */ crash?(actor: ProbeActor): Promise; /** Only where "fake-time". */ diff --git a/conformance/node/host.ts b/conformance/node/host.ts index fabac55..726ada7 100644 --- a/conformance/node/host.ts +++ b/conformance/node/host.ts @@ -32,16 +32,18 @@ import { cpSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createNodeSqlProvider } from "../../backends/node-sqlite"; -import type { Timer } from "../../src/index"; -import { AlarmScheduler, createActorContainer } from "../../src/index"; -import type { - ActorContainer, - ActorEntry, - FacetHandle, - FacetHost, - FacetId, - FacetStartRequest, - FacetTree, +import { + AlarmScheduler, + createActorContainer, + installWebSocketGlobals, + type ActorContainer, + type ActorEntry, + type FacetHandle, + type FacetHost, + type FacetId, + type FacetStartRequest, + type FacetTree, + type Timer, } from "../../src/index"; import type { ActorClassChannel, @@ -54,8 +56,21 @@ import { asLoopbackDurableObjectClass, LoopbackDurableObjectClass, } from "../../src/api/export-loopback"; -import type { Capability, ConformanceHost, ProbeActor } from "../host"; +import type { + Capability, + ConformanceHost, + LaneClientSocket, + LaneSocketMessage, + ProbeActor, +} from "../host"; import { Probe } from "../fixtures/probe"; +import { HibernationMirror } from "../../examples/platform-shims/hibernation-mirror"; +import { + installWebSocketUpgradeGlobals, + upgradeWebSocket, + webSocketUpgradeRequest, + type UpgradeWebSocket, +} from "../websocket-upgrade"; // ======================================================================================= // The platform globals workerd has natively @@ -163,6 +178,24 @@ globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { return container.globals.fetch(input, init); }) as typeof globalThis.fetch; +const ActorWebSocketPair: typeof WebSocketPair = new Proxy( + class WebSocketPair { + declare readonly 0: WebSocket; + declare readonly 1: WebSocket; + }, + { + construct() { + const Pair = current.getStore()?.globals.WebSocketPair; + if (Pair === undefined) { + throw new Error("Node lane: WebSocketPair was constructed outside an actor event."); + } + return new Pair(); + }, + }, +); +installWebSocketGlobals(globalThis, ActorWebSocketPair); +installWebSocketUpgradeGlobals(); + /** * `crypto`, on the same ambient as the timers above. * @@ -476,6 +509,62 @@ type Placement = { readonly stub: object; }; +class NodeClientSocket implements LaneClientSocket { + readonly #messages: LaneSocketMessage[] = []; + readonly #messageWaiters: ((message: LaneSocketMessage) => void)[] = []; + readonly #closes: { code: number; reason: string; wasClean: boolean }[] = []; + readonly #closeWaiters: ((close: { code: number; reason: string; wasClean: boolean }) => void)[] = []; + + constructor(readonly socket: UpgradeWebSocket) { + socket.addEventListener("message", (event) => { + const message = (event as MessageEvent).data as LaneSocketMessage; + const waiter = this.#messageWaiters.shift(); + if (waiter === undefined) this.#messages.push(message); + else waiter(message); + }); + socket.addEventListener("close", (event) => { + const closeEvent = event as CloseEvent; + const close = { + code: closeEvent.code, + reason: closeEvent.reason, + wasClean: closeEvent.wasClean, + }; + const waiter = this.#closeWaiters.shift(); + if (waiter === undefined) this.#closes.push(close); + else waiter(close); + }); + socket.accept(); + } + + get readyState(): number { + return this.socket.readyState; + } + + send(data: string | ArrayBuffer | ArrayBufferView): Promise { + this.socket.send(data); + return Promise.resolve(); + } + + close(code?: number, reason?: string): Promise { + this.socket.close(code, reason); + return Promise.resolve(); + } + + nextMessage(): Promise { + const message = this.#messages.shift(); + return message === undefined + ? new Promise((resolve) => this.#messageWaiters.push(resolve)) + : Promise.resolve(message); + } + + nextClose(): Promise<{ code: number; reason: string; wasClean: boolean }> { + const close = this.#closes.shift(); + return close === undefined + ? new Promise((resolve) => this.#closeWaiters.push(resolve)) + : Promise.resolve(close); + } +} + /** * One host for the whole run, as `FacetHost`'s contract requires: every id it is * handed is an id in some root's tree index, so it has to be able to place and @@ -637,6 +726,15 @@ function alarmScheduler(): Promise { } const live = new Map(); +const socketHosts = new Map(); + +function socketHost(name: string): HibernationMirror { + const existing = socketHosts.get(name); + if (existing !== undefined) return existing; + const host = new HibernationMirror(); + socketHosts.set(name, host); + return host; +} async function placed(name: string): Promise { return live.get(name) ?? (await place(name)); @@ -651,6 +749,7 @@ async function place(name: string): Promise { mkdirSync(directory, { recursive: true }); const host = new NodeFacetHost(); + const hibernation = socketHost(name); const scheduler = await alarmScheduler(); // `LOADER` is filled in below: the binding needs the container's IoContext, exactly as // upstream's binding compilation runs after the Worker exists. `env` is the same object the @@ -674,11 +773,13 @@ async function place(name: string): Promise { alarms: scheduler.hooks(name), facets: host, timer, + hibernation, fetch: async () => { await timer.afterDelay(60); return new Response("fetched"); }, }, + webSockets: hibernation.snapshot(), }); // ← `WorkerdApi::compileGlobals`'s `Global::WorkerLoader` arm — the real binding over the // lane's own namespace. `CODE_VERSION` is what workerd itself passes @@ -773,6 +874,21 @@ export const host: ConformanceHost = { return actor(previous.name); }, + connect: async (target, tags = []) => { + const record = await placed(target.name); + const response = await record.stub.fetch( + webSocketUpgradeRequest("https://probe.invalid/socket", tags), + ); + const socket = upgradeWebSocket(response); + if (socket === undefined) throw new Error("Node lane: probe fetch did not upgrade."); + return new NodeClientSocket(socket); + }, + + evict: async (target) => { + teardown(target.name); + await place(target.name); + }, + /** Drop the container without letting it flush: the files are all that survives. */ crash: async (target) => { teardown(target.name); diff --git a/conformance/suite/hibernation.spec.ts b/conformance/suite/hibernation.spec.ts new file mode 100644 index 0000000..151d52a --- /dev/null +++ b/conformance/suite/hibernation.spec.ts @@ -0,0 +1,442 @@ +/** + * Hibernatable WebSockets, measured against the pinned workerd lane and then + * replayed unchanged against the two do-runtime lanes. + */ + +import { describe, expect, it } from "vitest"; +import { host } from "conformance:host"; +import type { ProbeActor } from "../host"; + +type CapturedError = { name: string; message: string }; + +async function eventually(read: () => Promise, ready: (value: T) => boolean): Promise { + const deadline = Date.now() + 5_000; + let value = await read(); + while (!ready(value) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + value = await read(); + } + expect(ready(value)).toBe(true); + return value; +} + +async function journal(actor: ProbeActor): Promise<{ + events: Record[]; + trace: string[]; + times: { event: string; at: number }[]; + listenerMessages: number; + clients: Record; + closes: Record; + listed: string[]; +}> { + return await actor.call("socketJournal"); +} + +describe("acceptWebSocket and getWebSockets", () => { + it("A1-A4 refuses cross-accepts, enforces pair use, and permits the client half", async () => { + const actor = await host.spawn("ws-accept"); + expect(await actor.call("acceptanceSemantics")).toEqual({ + doubleHibernation: { + name: "Error", + message: "Cannot call `acceptWebSocket()` if the WebSocket was already accepted via `accept()`", + }, + classicThenHibernation: { + name: "Error", + message: "Cannot call `acceptWebSocket()` if the WebSocket was already accepted via `accept()`", + }, + hibernationThenClassic: { + name: "TypeError", + message: "Can't accept() WebSocket after enabling hibernation.", + }, + clientHalfAccepted: ["client-half"], + usedPair: { + name: "Error", + message: + "Cannot call `acceptWebSocket()` on this WebSocket because its pair has already been accepted or used in a Response.", + }, + }); + + expect(await actor.call("acceptAfterAwait")).toEqual(["after-await"]); + await actor.call("stashSocketForLaterEvent"); + // An unused pair survives across events. Pair USE, not pair creation, is the boundary. + expect(await actor.call("acceptSocketFromLaterEvent")).toBeNull(); + }); + + it("A5 coerces, de-duplicates, and bounds tags with workerd's errors", async () => { + const actor = await host.spawn("ws-tags"); + const result = await actor.call<{ + normalized: string[]; + tooMany: CapturedError; + tooLong: CapturedError; + nonArray: CapturedError; + }>("tagSemantics"); + const longTag = "x".repeat(257); + expect(result).toEqual({ + normalized: ["", "123", "null", "[object Object]", "dup"], + tooMany: { + name: "Error", + message: "a Hibernatable WebSocket cannot have more than 10 tags", + }, + tooLong: { + name: "Error", + message: `"${longTag}" is longer than the max tag length (256 characters).`, + }, + nonArray: { + name: "TypeError", + message: + "Failed to execute 'acceptWebSocket' on 'DurableObjectState': parameter 2 is not of type 'Array'.", + }, + }); + }); + + it("B1-B3 returns snapshots with LIFO unfiltered and FIFO tagged ordering", async () => { + const actor = await host.spawn("ws-order"); + expect(await actor.call("orderingSemantics")).toEqual({ + all: ["third", "second", "first"], + shared: ["first", "second"], + alpha: ["first"], + lower: [], + empty: [], + nonString: [], + freshArray: true, + sameObjects: true, + }); + }); + + it("I enforces the per-instance 32768 socket cap", async () => { + const actor = await host.spawn("ws-capacity"); + expect(await actor.call("socketCapacity")).toEqual({ + name: "Error", + message: "only 32768 websockets can be accepted on a single Durable Object instance", + }); + }, 60_000); +}); + +describe("attachments", () => { + it("C1-C7 snapshots structured clones, distinguishes undefined, and works on any socket", async () => { + const actor = await host.spawn("ws-attachments"); + expect(await actor.call("attachmentSemantics")).toEqual({ + snapshot: 1, + freshClone: true, + neverSerialized: null, + explicitUndefined: "undefined", + zeroArgument: { + name: "TypeError", + message: + "Failed to execute 'serializeAttachment' on 'WebSocket': parameter 1 is not of type 'Value'.", + }, + rich: { map: true, date: true, bigint: "bigint", bytes: true, cyclic: true }, + functionError: { + name: "DataCloneError", + message: "function foo() {} could not be cloned.", + }, + symbolError: { name: "DataCloneError", message: "Symbol(s) could not be cloned." }, + sizePass: null, + sizeFail: { + name: "Error", + message: + "A WebSocket 'attachment' cannot be larger than 16384 bytes.'attachment' was 16385 bytes.", + }, + classic: "classic", + client: "closed", + }); + }); +}); + +describe("handler dispatch and close state", () => { + it("D1-D3 dispatches methods only and normalizes binary messages to ArrayBuffer", async () => { + const actor = await host.spawn("ws-dispatch"); + await actor.call("openSelfSocket", "socket", ["socket"]); + await actor.call("sendSelf", "socket", "hello"); + await actor.call("sendSelfBinary", "socket", [1, 2, 3]); + await actor.call("sendSelf", "socket", "echo"); + + const result = await eventually( + () => journal(actor), + (value) => value.events.length >= 3 && value.clients.socket?.includes("echoed"), + ); + expect(result.listenerMessages).toBe(0); + expect(result.events.slice(0, 3)).toEqual([ + { id: "socket", message: { kind: "string", value: "hello" } }, + { id: "socket", message: { kind: "ArrayBuffer", value: [1, 2, 3] } }, + { id: "socket", message: { kind: "string", value: "echo" } }, + ]); + expect(result.clients.socket).toContain("echoed"); + }); + + it("D2 silently drops missing and throwing handlers and keeps later delivery alive", async () => { + const actor = await host.spawn("ws-handler-failures"); + await actor.call("openSelfSocket", "socket", ["socket"]); + await actor.call("removeSocketMessageHandler"); + await actor.call("sendSelf", "socket", "missing"); + await new Promise((resolve) => setTimeout(resolve, 50)); + await actor.call("restoreSocketMessageHandler"); + await actor.call("throwOnNextSocketMessage"); + await actor.call("sendSelf", "socket", "throws"); + await new Promise((resolve) => setTimeout(resolve, 50)); + await actor.call("sendSelf", "socket", "survives"); + + const result = await eventually( + () => journal(actor), + (value) => value.events.some((event) => JSON.stringify(event).includes("survives")), + ); + expect(result.events).toEqual([ + { id: "socket", message: { kind: "string", value: "survives" } }, + ]); + expect(result.listed).toEqual(["socket"]); + expect(result.closes.socket).toEqual([]); + }); + + it("D4 overlaps handler promises but waits for blockConcurrencyWhile", async () => { + const concurrent = await host.spawn("ws-concurrent"); + await concurrent.call("openSelfSocket", "socket", ["socket"]); + await concurrent.call("sendSelf", "socket", "slow:one"); + await new Promise((resolve) => setTimeout(resolve, 60)); + await concurrent.call("sendSelf", "socket", "slow:two"); + const overlapping = await eventually( + () => journal(concurrent), + (value) => value.trace.length === 4, + ); + expect(overlapping.trace).toEqual([ + "start:slow:one", + "start:slow:two", + "end:slow:one", + "end:slow:two", + ]); + expect(overlapping.times[1]!.at - overlapping.times[0]!.at).toBeLessThan(180); + + const blocked = await host.spawn("ws-blocked"); + await blocked.call("openSelfSocket", "socket", ["socket"]); + await blocked.call("sendSelf", "socket", "block:one"); + await new Promise((resolve) => setTimeout(resolve, 60)); + await blocked.call("sendSelf", "socket", "block:two"); + const serialized = await eventually( + () => journal(blocked), + (value) => value.trace.length === 4, + ); + expect(serialized.trace).toEqual([ + "start:block:one", + "end:block:one", + "start:block:two", + "end:block:two", + ]); + expect(serialized.times[2]!.at - serialized.times[0]!.at).toBeGreaterThanOrEqual(190); + }); + + it("D5/B4 reports peer close while listed, tolerates reciprocity, then evicts", async () => { + const actor = await host.spawn("ws-peer-close"); + await actor.call("openSelfSocket", "socket", ["socket"]); + await actor.call("closeSelfClient", "socket", 4001, "bye"); + const result = await eventually( + () => journal(actor), + (value) => value.events.some((event) => "close" in event), + ); + expect(result.events.at(-1)).toEqual({ + id: "socket", + close: { code: 4001, reason: "bye", wasClean: true }, + readyState: 2, + listedDuringHandler: false, + sendAfterPeerClose: null, + reciprocalClose: null, + }); + expect((await journal(actor)).listed).toEqual([]); + }); + + it("D6-D7 own close echoes, send-after-close throws synchronously, and close validates", async () => { + const actor = await host.spawn("ws-own-close"); + await actor.call("openSelfSocket", "socket", ["socket"]); + expect(await actor.call("sendAfterOwnClose", "socket")).toEqual({ + name: "TypeError", + message: "Can't call WebSocket send() after close().", + }); + const result = await eventually( + () => journal(actor), + (value) => value.events.some((event) => "close" in event), + ); + expect(result.events.at(-1)).toEqual({ + id: "socket", + close: { code: 4002, reason: "server out", wasClean: true }, + readyState: 3, + listedDuringHandler: false, + sendAfterPeerClose: { + name: "TypeError", + message: "Can't call WebSocket send() after close().", + }, + reciprocalClose: null, + }); + + const invalidCode = (code: number): CapturedError => ({ + name: "InvalidAccessError", + message: `Invalid WebSocket close code: ${code}.`, + }); + expect(await actor.call("closeValidation")).toEqual({ + code999: invalidCode(999), + code1005: invalidCode(1005), + code1006: invalidCode(1006), + code5000: invalidCode(5000), + longReason: { + name: "SyntaxError", + message: "WebSocket close reason must not be longer than 123 bytes when UTF-8 encoded.", + }, + code1000: null, + code3000: null, + code4999: null, + }); + }); + + it("D7 exposes ready states on the constructor and prototype", async () => { + const actor = await host.spawn("ws-ready-state"); + expect(await actor.call("readyStateConstants")).toEqual({ + fresh: [1, 1], + constructorValues: [0, 1, 2, 3, 0, 1, 2, 3], + prototype: [0, 1, 2, 3, 0, 1, 2, 3], + }); + }); + + it("keeps root and facet socket registries isolated", async () => { + const actor = await host.spawn("ws-facet-isolation"); + expect(await actor.call("facetSocketIsolation")).toEqual([1, 0, 1]); + }); +}); + +describe("auto-response, timeout, pair, and tags", () => { + it("E1-E5 auto-responds exact text, stamps the socket, and clears with undefined", async () => { + const actor = await host.spawn("ws-auto-response"); + await actor.call("openSelfSocket", "socket", ["socket"]); + await actor.call("setAutoResponse", "ping", "pong"); + await actor.call("sendSelf", "socket", "ping"); + const answered = await eventually( + () => journal(actor), + (value) => value.clients.socket?.includes("pong"), + ); + expect(answered.events).toEqual([]); + const auto = await actor.call<{ + value: { request: string; response: string }; + fresh: boolean; + timestamp: number; + unacceptedTimestamp: null; + badTimestamp: CapturedError; + nullSetter: CapturedError; + }>("autoResponseSemantics", "socket"); + expect(auto.value).toEqual({ request: "ping", response: "pong" }); + expect(auto.fresh).toBe(true); + expect(auto.timestamp).toBeTypeOf("number"); + expect(auto.unacceptedTimestamp).toBeNull(); + expect(auto.badTimestamp).toEqual({ + name: "TypeError", + message: + "Failed to execute 'getWebSocketAutoResponseTimestamp' on 'DurableObjectState': parameter 1 is not of type 'WebSocket'.", + }); + expect(auto.nullSetter).toEqual({ + name: "TypeError", + message: + "Failed to execute 'setWebSocketAutoResponse' on 'DurableObjectState': parameter 1 is not of type 'WebSocketRequestResponsePair'.", + }); + + await actor.call("sendSelf", "socket", "Ping"); + await actor.call("sendSelfBinary", "socket", [...new TextEncoder().encode("ping")]); + await eventually(() => journal(actor), (value) => value.events.length === 2); + await actor.call("clearAutoResponse"); + await actor.call("sendSelf", "socket", "ping"); + expect((await eventually(() => journal(actor), (value) => value.events.length === 3)).events).toHaveLength(3); + }); + + it("E6 bounds each auto-response side at 2048 UTF-8 bytes", async () => { + const actor = await host.spawn("ws-auto-limits"); + expect(await actor.call("autoResponseLimits")).toEqual({ + request: { + name: "RangeError", + message: "Request cannot be larger than 2048 bytes. A request of size 2049 was provided.", + }, + response: { + name: "RangeError", + message: "Response cannot be larger than 2048 bytes. A response of size 2049 was provided.", + }, + }); + }); + + it("F stores, coerces, bounds, and clears the event timeout", async () => { + const actor = await host.spawn("ws-timeout"); + expect(await actor.call("timeoutSemantics")).toEqual({ + initial: null, + thousand: 1_000, + truncated: 1, + coerced: 42, + negative: { + name: "TypeError", + message: + "The value cannot be converted because it is negative and this API expects a positive number.", + }, + outOfRange: { + name: "TypeError", + message: "Value out of range. Must be less than or equal to 4294967295.", + }, + sevenDays: { name: "Error", message: "Event timeout should not exceed 604800000 ms." }, + nan: { + name: "TypeError", + message: "The value cannot be converted because it is not an integer.", + }, + cleared: null, + }); + }); + + it("G implements WebSocketRequestResponsePair as a coercing read-only value", async () => { + const actor = await host.spawn("ws-pair-class"); + const result = await actor.call<{ + values: string[]; + json: string; + hasSetter: boolean; + withoutNew: CapturedError; + badCoercion: CapturedError; + }>("pairClassSemantics"); + expect(result.values).toEqual(["123", "null"]); + expect(result.json).toBe("{}"); + expect(result.hasSetter).toBe(false); + expect(result.withoutNew.message).toContain("Failed to construct 'WebSocketRequestResponsePair'"); + expect(result.badCoercion).toEqual({ name: "Error", message: "coercion failed" }); + }); + + it("H distinguishes unaccepted, classic, and untagged hibernatable sockets", async () => { + const actor = await host.spawn("ws-get-tags"); + expect(await actor.call("getTagsSemantics")).toEqual({ + unaccepted: { + name: "Error", + message: + "you must call 'acceptWebSocket()' before attempting to access the tags of a WebSocket.", + }, + classic: { + name: "Error", + message: "only hibernatable websockets can have tags.", + }, + tags: [], + fresh: true, + }); + }); +}); + +it("preserves tags and attachment across a real eviction without reconnecting", async () => { + const actor = await host.spawn("ws-eviction"); + const client = await host.connect(actor, ["connection-id", "room"]); + await actor.call("setHibernationMarker", "dirty-instance"); + await host.evict(actor); + await client.send("after-wake"); + + const observation = await eventually( + () => actor.call | null>("readExternalObservation"), + (value) => value !== null, + ); + expect(observation).toEqual({ + id: "connection-id", + message: { kind: "string", value: "after-wake" }, + attachment: { + id: "connection-id", + tags: ["connection-id", "room"], + marker: "attachment-survived", + }, + tags: ["connection-id", "room"], + listed: true, + actorName: "ws-eviction", + marker: "init", + connects: 1, + }); +}); diff --git a/conformance/websocket-upgrade.ts b/conformance/websocket-upgrade.ts new file mode 100644 index 0000000..bc72f02 --- /dev/null +++ b/conformance/websocket-upgrade.ts @@ -0,0 +1,45 @@ +import { markWebSocketUsed, type RawWebSocket } from "../src/index"; + +export type UpgradeWebSocket = RawWebSocket & { + accept(): void; + readonly readyState: number; +}; + +type UpgradeResponseInit = ResponseInit & { webSocket?: UpgradeWebSocket }; + +let installed = false; + +/** The Request/Response half of WebSocket upgrades supplied by workerd in the oracle lane. */ +export function installWebSocketUpgradeGlobals(): void { + if (installed) return; + installed = true; + const NativeResponse = globalThis.Response; + class WorkersResponse extends NativeResponse { + constructor(body?: BodyInit | null, init: UpgradeResponseInit = {}) { + const upgrade = init.status === 101; + const { webSocket, ...nativeInit } = init; + super(body, upgrade ? { ...nativeInit, status: 200 } : nativeInit); + if (upgrade) Object.defineProperty(this, "status", { value: 101 }); + if (webSocket !== undefined) { + markWebSocketUsed(webSocket); + Object.defineProperty(this, "webSocket", { value: webSocket }); + } + } + } + globalThis.Response = WorkersResponse as typeof Response; +} + +export function upgradeWebSocket(response: Response): UpgradeWebSocket | undefined { + return (response as Response & { webSocket?: UpgradeWebSocket }).webSocket; +} + +/** Preserve workerd's upgrade signal when browser Request.clone() drops forbidden headers. */ +export function webSocketUpgradeRequest(url: string, tags: readonly string[]): Request { + const request = new Request(`${url}?${tags.map((tag) => `tag=${encodeURIComponent(tag)}`).join("&")}`); + const get = request.headers.get.bind(request.headers); + Object.defineProperty(request.headers, "get", { + value: (name: string): string | null => + name.toLowerCase() === "upgrade" ? "websocket" : get(name), + }); + return request; +} diff --git a/conformance/workerd/host.ts b/conformance/workerd/host.ts index c9baeb2..bcfdfa8 100644 --- a/conformance/workerd/host.ts +++ b/conformance/workerd/host.ts @@ -3,12 +3,18 @@ * every assertion is measuring Cloudflare's runtime. */ -import { env } from "cloudflare:test"; -import type { Capability, ConformanceHost, ProbeActor } from "../host"; +import { env, evictDurableObject } from "cloudflare:test"; +import type { + Capability, + ConformanceHost, + LaneClientSocket, + LaneSocketMessage, + ProbeActor, +} from "../host"; type ProbeNamespace = { idFromName(name: string): unknown; - get(id: unknown): Record Promise>; + get(id: unknown): DurableObjectStub & Record Promise>; }; const probes = () => (env as unknown as { PROBE: ProbeNamespace }).PROBE; @@ -37,13 +43,69 @@ function actor(name: string): ProbeActor { }; } +function stubFor(actor: ProbeActor): DurableObjectStub { + const namespace = probes(); + return namespace.get(namespace.idFromName(actor.name)); +} + +function clientSocket(socket: WebSocket): LaneClientSocket { + const messages: LaneSocketMessage[] = []; + const messageWaiters: ((message: LaneSocketMessage) => void)[] = []; + const closes: { code: number; reason: string; wasClean: boolean }[] = []; + const closeWaiters: ((event: { code: number; reason: string; wasClean: boolean }) => void)[] = []; + + socket.accept(); + socket.addEventListener("message", (event) => { + const message = event.data as LaneSocketMessage; + const waiter = messageWaiters.shift(); + if (waiter === undefined) messages.push(message); + else waiter(message); + }); + socket.addEventListener("close", (event) => { + const closed = { code: event.code, reason: event.reason, wasClean: event.wasClean }; + const waiter = closeWaiters.shift(); + if (waiter === undefined) closes.push(closed); + else waiter(closed); + }); + + return { + get readyState() { + return socket.readyState; + }, + send: async (data) => { + socket.send(data); + }, + close: async (code, reason) => { + socket.close(code, reason); + }, + nextMessage: async () => + messages.shift() ?? + (await new Promise((resolve) => { + messageWaiters.push(resolve); + })), + nextClose: async () => + closes.shift() ?? + (await new Promise<{ code: number; reason: string; wasClean: boolean }>((resolve) => { + closeWaiters.push(resolve); + })), + }; +} + export const host: ConformanceHost = { lane: "workerd", - // Native here, throwing stubs in ours — `substrate()` asserts both sides. - capabilities: new Set([ - "hibernation", - "bookmarks", - ]), + capabilities: new Set(["bookmarks"]), spawn: async (name = `probe-${probeCounter++}`) => actor(name), respawn: async (a) => actor(a.name), + connect: async (target, tags = []) => { + const url = new URL("http://probe/hibernation"); + for (const tag of tags) url.searchParams.append("tag", tag); + const response = await stubFor(target).fetch(url, { headers: { Upgrade: "websocket" } }); + if (response.status !== 101 || response.webSocket === null) { + throw new Error(`Probe upgrade failed with status ${response.status}.`); + } + return clientSocket(response.webSocket); + }, + evict: async (target) => { + await evictDurableObject(stubFor(target), { webSockets: "hibernate" }); + }, }; diff --git a/docs/decisions.md b/docs/decisions.md index 6967b29..42dd25b 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -79,9 +79,19 @@ resumption. ### §1.8 Alarms and WebSockets -Alarm delivery and accepted WebSocket frames are actor events. They enter the -owning container and do not overlap another event admitted by its gate. -Hibernatable WebSockets are not available on this substrate. +Alarm delivery and accepted WebSocket frames are actor events. Classic sockets +dispatch listeners through the context captured by `accept()`; hibernatable +sockets dispatch `webSocketMessage`, `webSocketClose`, and `webSocketError` +through fresh entries on the owning container. Handler promises may overlap; +`blockConcurrencyWhile()` is the explicit serialization boundary. + +The runtime owns `WebSocketPair`, tags, attachments, auto-responses, timeouts, +and the live per-container registry. A host that may evict a container mirrors +accepted sockets through `ports.hibernation` and passes them back through +`options.webSockets`; they are registered before the next constructor runs. +Pair construction is not tied to one event—using either half via `accept()` or +a 101 Response is the boundary. A closed socket leaves `getWebSockets()` before +its close handler runs. ### §1.9 `waitUntil` @@ -153,9 +163,9 @@ but it is not the time-indexed or continuously replicated Cloudflare service. ### §2.5 Fail-closed substrate boundaries -Hibernation, point-in-time recovery, replication, actor-class stub -serialization, and unsupported module-scope Workers features throw named -errors. They do not return empty values or silently downgrade behavior. +Point-in-time recovery, replication, actor-class stub serialization, and +unsupported module-scope Workers features throw named errors. They do not +return empty values or silently downgrade behavior. ### §2.6 Alarm ownership @@ -233,7 +243,7 @@ owns only placement and physical storage operations. | Difference from production workerd | Contract here | | --- | --- | -| No hibernation while retaining sockets | Hibernatable WebSocket APIs throw; applications use memory-only sockets and reconnect | +| Workerd owns socket transport retention across eviction internally | A local embedder mirrors socket references, tags, and attachment bytes through `ports.hibernation`, then rehydrates the next container through `options.webSockets` | | JavaScript cannot terminate the currently executing slice | `abort()` breaks later storage and entry, but the calling method can still return | | No common V8 byte serializer across Node and the browser | A versioned browser-safe structured-clone encoding preserves the public value types and reads legacy JSON rows | | The SQLite backends expose no authorizer callbacks | Reserved `_cf_` identifiers are detected from tokenized statement text and may reject more than workerd | diff --git a/docs/gating-coverage.md b/docs/gating-coverage.md index f05cc0f..885e874 100644 --- a/docs/gating-coverage.md +++ b/docs/gating-coverage.md @@ -36,7 +36,7 @@ enumerates it. Every row is one of: | `setTimeout` / `setInterval` | arming captures the critical section; firing re-enters via `ctx.run` | `api/global-scope.ts` | | `scheduler.wait()` / `scheduler.yield()` | scoped `Scheduler` over the same timer path | `api/global-scope.ts` | | `crypto.subtle.*` | every method's promise gated; sync members pass through | `api/global-scope.ts` | -| `WebSocket` | frames each take a fresh input lock at the `accept()` loop; `send` carries its own output-gate promise (§1.8) | `api/web-socket.ts` | +| `WebSocket` | classic listener delivery re-enters its captured context; hibernatable frames take fresh input locks and dispatch class methods; `send` carries its own output-gate promise (§1.8) | `api/web-socket.ts` | | storage / `sql` / alarms / `blockConcurrencyWhile` / `awaitIo` / `makeReentryCallback` / entry and loopback dispatch | the runtime's own primitives | `io/io-context.ts`, `server/actor-container.ts` | ## Transform diff --git a/examples/extension/README.md b/examples/extension/README.md index 1d56223..2792eed 100644 --- a/examples/extension/README.md +++ b/examples/extension/README.md @@ -65,13 +65,14 @@ popup.html ──sendMessage──▶ service worker ──chrome.offscreen.crea sibling isolation, overlapping awaits, nested children, restart persistence, abort-versus-delete storage semantics, and a child schedule delivered after Chrome recreates an evicted host. -- **A real non-hibernating `AgentClient` connection.** The offscreen page opens - the SDK client over a `MessagePort`-backed WebSocket, while the actor receives - the server half through `routeAgentRequest()` and - `container.acceptWebSocket()`. The e2e proves standard named routing, - `getAgentByName()` direct stubs, server-to-client state broadcasts, - client-to-server `setState()`, a decorated `@callable()` method, and a - streaming callable's chunks and final value. +- **A hibernatable `AgentClient` connection across container eviction.** The + offscreen page opens the SDK client over a `MessagePort`-backed WebSocket, + while the actor receives the server half through `routeAgentRequest()` and + `ctx.acceptWebSocket()`. The e2e replaces only the root actor container and + proves that the same client receives a new state broadcast and writes state + back to the replacement without reconnecting. It also covers standard named + routing, `getAgentByName()` direct stubs, a decorated `@callable()` method, + and a streaming callable's chunks and final value. - **The SDK's stateless MCP handler.** The actor serves `createMcpHandler()` and exposes a real `McpServer` tool that reads its current state. The e2e performs MCP `tools/list` and `tools/call` requests through the gated actor fetch path. @@ -112,7 +113,7 @@ cd examples/extension && node scripts/e2e.mjs It builds first, launches a headless Chromium with a throwaway profile in `.e2e-profile/`, loads `dist/` as an unpacked extension, and prints a `PASS`/`FAIL` -line per assertion. It exits non-zero on the first failure. +line per assertion. It exits non-zero if any assertion fails. `playwright` resolves from the repository root's `node_modules`, which is why the script is plain `.mjs` with a dynamic import rather than a dependency of this @@ -133,7 +134,8 @@ only as a competing supervisor and asserts that Web Locks refuse it before OPFS. | `src/background.ts` | The service worker: offscreen lifecycle and `chrome.alarms` projection. | | `src/popup/popup.ts` | Four buttons and an output pane. | | `src/protocol.ts` | The types both TypeScript projects compile. It imports nothing. | -| `../platform-shims/memory-websocket-pair.ts` | A local WebSocket pair for the Agents server path. | +| `../platform-shims/memory-websocket-pair.ts` | The browser `Response`-101 shim; the runtime supplies `WebSocketPair`. | +| `../platform-shims/hibernation-mirror.ts` | The process-local `HibernationHost` record used by this example and the conformance embedders. | | `../platform-shims/message-port-websocket.ts` | The client-side WebSocket adapter carried over a `MessagePort`. | | `public/manifest.json` | Copied verbatim into `dist/` by Vite's `publicDir`. | @@ -167,9 +169,13 @@ Every step is where it is because moving it was measured to fail. worker hosts one root, so "no container" cannot mean "outside any actor" — it can only mean the container was torn down mid-flight, and handing that continuation a raw timer would resume it ungated. -5. **Consume `container.onBroken`.** A host that ignores it gets an actor that +5. **Keep the hibernation mirror outside `place()`.** Each container writes its + accepted sockets, tags, and attachments through `ports.hibernation`; its + replacement receives `hibernation.snapshot()` as `webSockets` before the + actor constructor runs. +6. **Consume `container.onBroken`.** A host that ignores it gets an actor that answers nothing and logs nothing. -6. **Boot the worker with one raw `postMessage` carrying the `MessagePort` in the +7. **Boot the worker with one raw `postMessage` carrying the `MessagePort` in the transfer list.** A port is not a value capnweb can serialise. The DOM side opens capnweb directly; the actor side uses the runtime's `newRpcSession` so Workers `RpcTarget` values get the required prototype graft. @@ -180,6 +186,25 @@ Two values here are permanent: storage. Changing it silently orphans everything the extension has stored. - `POOL_NAME` — it becomes an OPFS directory name, so it may not contain `/`. +## The hibernation boundary + +`evict()` waits until `container.quiescence()` reports no armed timers, +`waitUntil` work, current input lock, or broken output gate. It then drops the +placement, closes that container's SQLite handle, and constructs a replacement +from the mirror. The offscreen document, actor Worker, `MessagePort`, raw socket, +and `AgentClient` stay alive, so there is no reconnect. + +The mirror is intentionally in memory. Destroying the offscreen document also +destroys its Worker and both ends of the local transport; socket metadata cannot +resurrect a transport that no longer exists. That path creates a new Worker and +lets a new `AgentClient` connect, while OPFS preserves the durable state. A host +whose platform owns sockets outside the actor process can keep the same mirror +records at that outer boundary. + +If such a host buffers frames during wake-up, it must construct and start the +replacement before flushing them. A container has no class instance to dispatch +to before `start()` completes. + ## One holder per pool An OPFS SAH pool takes an **exclusive** sync access handle on every one of its @@ -213,7 +238,7 @@ substrate. Cloudflare-managed products remain explicit integration boundaries: | Runs in this host | Needs a Cloudflare service or separate integration | | --- | --- | | HTTP and durable state | Workflows: a real Workflow binding and Workflow runtime | -| Non-hibernating WebSockets and bidirectional state sync | WebSocket hibernation: platform-owned socket survival across eviction | +| Hibernatable WebSockets and bidirectional state sync across same-Worker container eviction | Socket survival across Worker/offscreen destruction: a platform-owned transport | | Decorated callable and streaming RPC | AI chat and tool approval: `@cloudflare/ai-chat` plus a model provider | | SQLite-backed queue and root/sub-agent `Agent.schedule()` | Outbound email: an Email Routing send binding | | Local sub-agents, nesting, restart, abort, and delete | | @@ -228,8 +253,10 @@ Written down because this example exists partly to find them. Vite maps the Node imports through `unenv`; `cloudflare:email` remains a fail-closed shim. The inbound test supplies a host-created `ForwardableEmailMessage`; its forwarding and reply methods refuse because the - demo has no outbound Email Routing binding. Both demos disable Agent WebSocket - hibernation because this runtime deliberately refuses hibernatable sockets. + demo has no outbound Email Routing binding. +- **The hibernation mirror is not process durability.** It survives a container + replacement inside this Worker. It cannot survive destruction of the Worker + that owns the raw `MessagePort` socket; that lifecycle reconnects instead. - **The facet entry repeats the Agents SDK bundle.** The root worker and `counter-child.js` each carry their own copy (about 1.3 MB unminified for the child in this readable demo build). The separate entry is intentional: its diff --git a/examples/extension/scripts/e2e.mjs b/examples/extension/scripts/e2e.mjs index bcaa83d..64a943d 100644 --- a/examples/extension/scripts/e2e.mjs +++ b/examples/extension/scripts/e2e.mjs @@ -261,7 +261,42 @@ async function main() { ); // --------------------------------------------------------------------- - // 2. A real alarm: armed in the actor's storage, delivered by the + // 2. Root-container eviction keeps the existing hibernatable socket. The + // offscreen document, Worker, MessagePort and AgentClient all stay put; + // only the root actor container and its SQLite handle are replaced. + await op(popup, "evict"); + check("the replacement actor continues durable state", await op(popup, "increment"), 13); + + let stateAfterEviction = await op(popup, "sdkState"); + for (let attempts = 0; attempts < 20 && stateAfterEviction.value !== 13; attempts += 1) { + await new Promise((resolve) => setTimeout(resolve, 50)); + stateAfterEviction = await op(popup, "sdkState"); + } + check( + "the connected Agents client receives state after container eviction", + stateAfterEviction.value, + 13, + ); + + const clientStateAfterEviction = await op(popup, "sdkSetState", [20]); + check( + "the connected Agents client remains writable after container eviction", + clientStateAfterEviction.value, + 20, + ); + for (let attempts = 0; attempts < 20; attempts += 1) { + snapshot = await op(popup, "snapshot"); + if (snapshot.value === 20) break; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + check( + "the replacement actor receives state over the rehydrated socket", + snapshot.value, + 20, + ); + + // --------------------------------------------------------------------- + // 3. A real alarm: armed in the actor's storage, delivered by the // AlarmScheduler's own database in the same worker. const childArmedFor = await op(popup, "armSubAgentWake", [5000]); if (typeof childArmedFor !== "number") { @@ -300,7 +335,7 @@ async function main() { await new Promise((resolve) => setTimeout(resolve, 250)); } check("chrome.alarms recreated the host and delivered the alarm", alarms, 1); - check("the alarm handler's write landed", snapshot.value, 13); + check("the alarm handler's write landed", snapshot.value, 21); check( "the recreated host delivered durable work to the sub-agent", await op(popup, "scheduledSubAgentValue"), @@ -308,12 +343,12 @@ async function main() { ); // --------------------------------------------------------------------- - // 3. Persistence across offscreen recreation: a new document, a new worker, a new + // 4. Persistence across offscreen recreation: a new document, a new worker, a new // container, the same OPFS files. await worker.evaluate(async () => chrome.offscreen.closeDocument()); await ensureHost(popup); snapshot = await op(popup, "snapshot"); - check("the Agent state survived offscreen recreation", snapshot.value, 13); + check("the Agent state survived offscreen recreation", snapshot.value, 21); check( "the alarm event survived offscreen recreation", snapshot.events.filter((event) => event.kind === "sdk-schedule").length, @@ -321,17 +356,17 @@ async function main() { ); const reconnectedState = await op(popup, "sdkState"); - check("a recreated Agents client resynced durable state", reconnectedState.value, 13); + check("a recreated Agents client resynced durable state", reconnectedState.value, 21); const afterReload = await op(popup, "sdkIncrement"); - check("a recreated Agents client called a decorated method", afterReload, 14); + check("a recreated Agents client called a decorated method", afterReload, 22); const restartedSubAgents = await op(popup, "subAgents"); check( "sub-agent state survived offscreen recreation", JSON.stringify(restartedSubAgents), JSON.stringify([ - { name: "alpha", value: 3, parentValue: 14 }, - { name: "beta", value: 3, parentValue: 14 }, + { name: "alpha", value: 3, parentValue: 22 }, + { name: "beta", value: 3, parentValue: 22 }, ]), ); check( @@ -346,17 +381,17 @@ async function main() { ); // --------------------------------------------------------------------- - // 4. Nothing broke in the background. + // 5. Nothing broke in the background. const status = await op(popup, "status"); check("the container never broke", status.broken, null); check("the alarm scheduler had no background failure", status.alarmTaskFailure, null); // --------------------------------------------------------------------- - // 5. The popup reaches that same real offscreen host. + // 6. The popup reaches that same real offscreen host. await popup.click("#increment"); const printed = await waitForOutput(popup, /^[^\n]+ increment\n/, BOOT_TIMEOUT_MS); const viaOffscreen = Number(printed.split("\n")[1]); - check("the offscreen document continues the same storage", viaOffscreen, 15); + check("the offscreen document continues the same storage", viaOffscreen, 23); const offscreenContexts = await worker.evaluate(async () => (await chrome.runtime.getContexts({ contextTypes: ["OFFSCREEN_DOCUMENT"] })).length, diff --git a/examples/extension/src/offscreen/offscreen.ts b/examples/extension/src/offscreen/offscreen.ts index 20da627..b3d51aa 100644 --- a/examples/extension/src/offscreen/offscreen.ts +++ b/examples/extension/src/offscreen/offscreen.ts @@ -145,6 +145,7 @@ const ops = { host.directStubIncrement() as unknown as Promise, email: (subject: string, body: string): Promise => host.email(subject, body) as unknown as Promise, + evict: (): Promise => host.evict() as unknown as Promise, increment: (): Promise => host.increment() as unknown as Promise, enqueueIncrement: (amount: number): Promise => host.enqueueIncrement(amount) as unknown as Promise, @@ -194,6 +195,8 @@ async function runOp(op: HostOp, args: readonly unknown[]): Promise { return await ops.directStubIncrement(); case "email": return await ops.email(String(args[0]), String(args[1])); + case "evict": + return await ops.evict(); case "increment": return await ops.increment(); case "enqueueIncrement": diff --git a/examples/extension/src/protocol.ts b/examples/extension/src/protocol.ts index 031e9b8..4d71c1f 100644 --- a/examples/extension/src/protocol.ts +++ b/examples/extension/src/protocol.ts @@ -81,6 +81,7 @@ export type HostStatus = { export interface HostRpc { directStubIncrement(): Promise; email(subject: string, body: string): Promise; + evict(): Promise; increment(): Promise; enqueueIncrement(amount: number): Promise; mcp(method: string, params: Record): Promise; diff --git a/examples/extension/src/worker/actor.worker.ts b/examples/extension/src/worker/actor.worker.ts index 2c9ad2b..9189c33 100644 --- a/examples/extension/src/worker/actor.worker.ts +++ b/examples/extension/src/worker/actor.worker.ts @@ -51,11 +51,12 @@ import sqlite3InitModule from "@sqlite.org/sqlite-wasm"; import { getAgentByName, routeAgentEmail, routeAgentRequest } from "agents"; import { RpcTarget } from "cloudflare:workers"; import { - installMemoryWebSocketPair, + installWebSocketUpgradeResponse, upgradeWebSocket, withWebSocketUpgrade, type UpgradeWebSocket, } from "../../../platform-shims/memory-websocket-pair"; +import { HibernationMirror } from "../../../platform-shims/hibernation-mirror"; import { serveMessagePortWebSockets } from "../../../platform-shims/message-port-websocket"; import type { CounterSnapshot, @@ -162,6 +163,8 @@ const ALARM_DATABASE = "scheduler"; * `SQLITE_CANTOPEN: sqlite3 result code 14`. This line is the knob. */ const POOL_CAPACITY = 64; +const EVICTION_POLL_MS = 10; +const EVICTION_TIMEOUT_MS = 5_000; // ======================================================================================= // Facet placement: bundled Agent classes, one database and gate set per child @@ -328,6 +331,8 @@ class ExtensionFacetHost implements FacetHost { } const facets = new ExtensionFacetHost(); +// Worker-owned, not placement-owned: accepted raw sockets survive a root replacement. +const hibernation = new HibernationMirror(); // ======================================================================================= // The container this worker hosts @@ -339,6 +344,7 @@ type Live = { * gated event, so this is the only handle anything outside the actor gets. */ readonly entry: ActorEntry; + readonly storage: SqliteWasmActorStorage; }; let live: Live | undefined; @@ -380,10 +386,7 @@ function counterNamespace(gate?: FacetModule["gate"]) { const rootNamespace = counterNamespace(); -installMemoryWebSocketPair(() => { - if (live === undefined) throw new Error("WebSocket upgrade reached an unplaced actor"); - return live.container; -}); +installWebSocketUpgradeResponse(); /** * What the installed globals resolve to, and it REFUSES rather than falling @@ -546,10 +549,12 @@ async function place(): Promise { alarms: scheduler.hooks(ACTOR_ID), facets, timer, + hibernation, // `ports.fetch` is deliberately omitted, which is upstream's // `globalOutbound: null` posture: `fetch` inside the actor refuses BY NAME // rather than reaching an ungated one that would appear to work. }, + webSockets: hibernation.snapshot(), }); rootGate.container = container; @@ -584,7 +589,7 @@ async function place(): Promise { storage.close(); throw error; } - live = { container, entry: container.entry(instance) }; + live = { container, entry: container.entry(instance), storage }; return live; } @@ -604,6 +609,28 @@ async function placed(): Promise { return await placing; } +/** Poll on the raw host timer; an actor-scoped delay would itself keep the actor busy. */ +async function waitUntilEvictable(container: ActorContainer): Promise { + const deadline = Date.now() + EVICTION_TIMEOUT_MS; + for (;;) { + const state = container.quiescence(); + if (state.outputGateBroken) { + throw new Error(`The actor output gate is broken: ${JSON.stringify(state)}`); + } + if ( + state.armedTimers === 0 && + state.pendingWaitUntil === 0 && + !state.inputLockHeld + ) { + return; + } + if (Date.now() >= deadline) { + throw new Error(`The actor did not become idle: ${JSON.stringify(state)}`); + } + await timer.afterDelay(EVICTION_POLL_MS); + } +} + // ======================================================================================= // The RPC surface // @@ -665,6 +692,14 @@ class HostTarget extends RpcTarget implements HostRpc { }); } + async evict(): Promise { + const current = await placed(); + await waitUntilEvictable(current.container); + live = undefined; + current.storage.close(); + await placed(); + } + async increment(): Promise { return await (await placed()).entry.increment(); } diff --git a/examples/extension/src/worker/counter-child.worker.ts b/examples/extension/src/worker/counter-child.worker.ts index 73f5abf..820d86c 100644 --- a/examples/extension/src/worker/counter-child.worker.ts +++ b/examples/extension/src/worker/counter-child.worker.ts @@ -20,7 +20,6 @@ class Counter extends Agent { } export class CounterChild extends Agent { - static override options = { hibernate: false }; override initialState: CounterState = { value: 0 }; async bump(): Promise { @@ -56,7 +55,6 @@ export class CounterChild extends Agent { } export class CounterLeaf extends Agent { - static override options = { hibernate: false }; override initialState: CounterState = { value: 0 }; async bump(): Promise { diff --git a/examples/extension/src/worker/counter.ts b/examples/extension/src/worker/counter.ts index 7ff517f..1797f04 100644 --- a/examples/extension/src/worker/counter.ts +++ b/examples/extension/src/worker/counter.ts @@ -45,7 +45,6 @@ export type CounterEnv = { Counter: DurableObjectNamespace }; type CounterState = { value: number }; export class Counter extends Agent { - static override options = { hibernate: false }; override initialState: CounterState = { value: 0 }; /** diff --git a/examples/platform-shims/hibernation-mirror.ts b/examples/platform-shims/hibernation-mirror.ts new file mode 100644 index 0000000..fa56f55 --- /dev/null +++ b/examples/platform-shims/hibernation-mirror.ts @@ -0,0 +1,38 @@ +import type { + HibernationHost, + RawWebSocket, + RehydratedWebSocket, +} from "@mcp-b/do-runtime"; + +type MirroredWebSocket = RehydratedWebSocket & { tags: readonly string[] }; + +/** In-memory socket state shared by the reference embedders and browser example. */ +export class HibernationMirror implements HibernationHost { + readonly #entries = new Map(); + autoResponsePair: { request: string; response: string } | null = null; + + accepted(socket: RawWebSocket, tags: readonly string[]): void { + this.#entries.set(socket, { socket, tags: [...tags] }); + } + + attachment(socket: RawWebSocket, bytes: Uint8Array | null): void { + const entry = this.#entries.get(socket); + if (entry === undefined) { + throw new Error("Hibernation mirror: attachment preceded socket acceptance."); + } + if (bytes === null) delete entry.attachment; + else entry.attachment = bytes.slice(); + } + + autoResponse(pair: { request: string; response: string } | null): void { + this.autoResponsePair = pair === null ? null : { ...pair }; + } + + closed(socket: RawWebSocket): void { + this.#entries.delete(socket); + } + + snapshot(): RehydratedWebSocket[] { + return [...this.#entries.values()]; + } +} diff --git a/examples/platform-shims/memory-websocket-pair.ts b/examples/platform-shims/memory-websocket-pair.ts index d953185..21e4029 100644 --- a/examples/platform-shims/memory-websocket-pair.ts +++ b/examples/platform-shims/memory-websocket-pair.ts @@ -1,78 +1,14 @@ -import type { ActorContainer } from "@mcp-b/do-runtime"; -import type { RawWebSocket } from "@mcp-b/do-runtime"; +import { markWebSocketUsed, type RawWebSocket } from "@mcp-b/do-runtime"; export type UpgradeWebSocket = EventTarget & RawWebSocket & { accept(): void; readonly readyState: number; }; -class MemoryWebSocket extends EventTarget implements UpgradeWebSocket { - #accepted = false; - #peer!: MemoryWebSocket; - #pending: Event[] = []; - readyState: number = WebSocket.CONNECTING; - - link(peer: MemoryWebSocket): void { - this.#peer = peer; - } - - accept(): void { - if (this.#accepted) return; - this.#accepted = true; - this.readyState = WebSocket.OPEN; - this.#peer.readyState = WebSocket.OPEN; - this.#deliver(new Event("open")); - for (const event of this.#pending.splice(0)) this.#deliver(event); - } - - send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void { - if (this.readyState !== WebSocket.OPEN) throw new DOMException("WebSocket is not open."); - this.#peer.receive(new MessageEvent("message", { data })); - } - - close(code = 1000, reason = ""): void { - if (this.readyState >= WebSocket.CLOSING) return; - this.readyState = WebSocket.CLOSED; - this.#peer.readyState = WebSocket.CLOSED; - const event = new CloseEvent("close", { code, reason, wasClean: true }); - this.receive(event); - this.#peer.receive(new CloseEvent("close", { code, reason, wasClean: true })); - } - - receive(event: Event): void { - if (this.#accepted) this.#deliver(event); - else this.#pending.push(event); - } - - #deliver(event: Event): void { - queueMicrotask(() => this.dispatchEvent(event)); - } -} - -type UpgradeResponseInit = Omit & { webSocket?: UpgradeWebSocket }; - -/** Install the two workerd upgrade primitives PartyServer uses in non-hibernating mode. */ -export function installMemoryWebSocketPair(resolve: () => ActorContainer): void { - const WebSocketWithWorkersConstants = WebSocket as typeof WebSocket & { - READY_STATE_OPEN?: number; - }; - WebSocketWithWorkersConstants.READY_STATE_OPEN ??= WebSocket.OPEN; - - const Pair = function (): Record<0 | 1, UpgradeWebSocket> { - const client = new MemoryWebSocket(); - const rawServer = new MemoryWebSocket(); - client.link(rawServer); - rawServer.link(client); - const server = resolve().acceptWebSocket(rawServer) as unknown as UpgradeWebSocket; - Object.defineProperties(server, { - accept: { value: () => rawServer.accept() }, - readyState: { get: () => rawServer.readyState }, - }); - return { 0: client, 1: server }; - }; - (globalThis as unknown as { WebSocketPair: typeof WebSocketPair }).WebSocketPair = - Pair as unknown as typeof WebSocketPair; +type UpgradeResponseInit = ResponseInit & { webSocket?: UpgradeWebSocket }; +/** Install the Response-101 half; `installActorScope` supplies the runtime's WebSocketPair. */ +export function installWebSocketUpgradeResponse(): void { const NativeResponse = globalThis.Response; class WorkersResponse extends NativeResponse { constructor(body?: BodyInit | null, init: UpgradeResponseInit = {}) { @@ -81,6 +17,7 @@ export function installMemoryWebSocketPair(resolve: () => ActorContainer): void super(body, upgrade ? { ...nativeInit, status: 200 } : nativeInit); if (upgrade) Object.defineProperty(this, "status", { value: 101 }); if (webSocket !== undefined) { + markWebSocketUsed(webSocket); Object.defineProperty(this, "webSocket", { value: webSocket }); } } @@ -89,7 +26,7 @@ export function installMemoryWebSocketPair(resolve: () => ActorContainer): void } export function upgradeWebSocket(response: Response): UpgradeWebSocket | undefined { - return (response as unknown as { webSocket?: UpgradeWebSocket }).webSocket; + return (response as Response & { webSocket?: UpgradeWebSocket }).webSocket; } /** Preserve workerd's WebSocket upgrade signal across browser `Request.clone()` calls. */ diff --git a/examples/platform-shims/message-port-websocket.ts b/examples/platform-shims/message-port-websocket.ts index ec4f469..3632955 100644 --- a/examples/platform-shims/message-port-websocket.ts +++ b/examples/platform-shims/message-port-websocket.ts @@ -9,6 +9,7 @@ type PortSocket = { accept(): void; send(data: unknown): void; close(code?: number, reason?: string): void; + readonly readyState: number; }; /** A browser WebSocket constructor backed by one multiplexed MessagePort. */ @@ -90,7 +91,13 @@ export function serveMessagePortWebSockets( void connect(message.url).then( (socket) => { sockets.set(message.id, socket); - socket.addEventListener("open", () => post(message)); + let opened = false; + const open = () => { + if (opened) return; + opened = true; + post(message); + }; + socket.addEventListener("open", open); socket.addEventListener("message", (incoming: Event) => { post({ type: "message", @@ -107,6 +114,7 @@ export function serveMessagePortWebSockets( post({ type: "error", id: message.id, message: "agent socket failed" }); }); socket.accept(); + if (socket.readyState === WebSocket.OPEN) open(); }, (error: unknown) => { post({ type: "error", id: message.id, message: String(error) }); diff --git a/examples/vibe-platform/README.md b/examples/vibe-platform/README.md index 9d666c9..b6d2057 100644 --- a/examples/vibe-platform/README.md +++ b/examples/vibe-platform/README.md @@ -110,10 +110,12 @@ failing; `VIBE_E2E_OFFLINE=1 node scripts/e2e.mjs` takes that path on purpose. **This is the Agents SDK's HTTP state path, not its whole platform.** The SDK eagerly imports Node and email modules, so Vite maps the Node imports through `unenv` and a fail-closed email shim. The -starter disables Agent WebSocket hibernation because this runtime refuses hibernatable sockets. The -[MV3 extension example](../extension/README.md) is the broader compatibility harness: it runs the -SDK client and non-hibernating socket server, bidirectional state sync, callable and streaming RPC, -the SDK queue and scheduler, stateless MCP, and inbound email routing. +starter disables Agent WebSocket hibernation because every source edit deliberately terminates the +Worker and its local transport; an in-Worker mirror would disappear with both. The [MV3 extension +example](../extension/README.md) is the broader compatibility harness: it keeps the transport alive +across container eviction and runs the SDK client, hibernatable socket server, bidirectional state +sync, callable and streaming RPC, the SDK queue and scheduler, stateless MCP, and inbound email +routing. ## Deploying an export @@ -137,8 +139,8 @@ later real deployment will succeed. - **Broader Agents SDK surfaces in authored code.** This lane intentionally proves the smallest useful exportable slice: `initialState`, `state`, `setState()`, and `onRequest()` through an actor restart, page reload, and deploy dry-run. The extension example covers the locally executable SDK - surfaces. Workflows, outbound email, AI chat/model calls, and WebSocket hibernation need real - platform bindings or a separate provider, so neither browser demo simulates them. + surfaces. Workflows, outbound email, and AI chat/model calls need real platform bindings or a + separate provider, so neither browser demo simulates them. - **Facets.** `ports.facets` refuses too. Facets are child actors with their own gates and their own database inside the parent's pool — the mechanism you would reach for to give each *project* in a platform its own storage under one root. diff --git a/examples/vibe-platform/src/worker/workspace.ts b/examples/vibe-platform/src/worker/workspace.ts index 3b645f7..bbc6f84 100644 --- a/examples/vibe-platform/src/worker/workspace.ts +++ b/examples/vibe-platform/src/worker/workspace.ts @@ -149,7 +149,6 @@ const STARTER: Record = { "/server/agent.ts": `import { Agent } from "agents"; export class MyAgent extends Agent { - static options = { hibernate: false }; initialState = { visits: 0, recent: [] }; async onRequest(request) { diff --git a/src/api/actor-state.test.ts b/src/api/actor-state.test.ts index 49853d7..da15f7b 100644 --- a/src/api/actor-state.test.ts +++ b/src/api/actor-state.test.ts @@ -60,9 +60,9 @@ import { FACET_CLASS_UNSUPPORTED_MESSAGE, FACET_NAME_MAX_LENGTH, FACET_TREE_MAX_DEPTH, - HIBERNATION_UNIMPLEMENTED_MESSAGE, type StorageCache, } from "./actor-state"; +import { HibernatableWebSocketRegistry } from "./web-socket"; /** The `ctx.exports` value a facet start-up callback hands back, once per call site. */ function testClass(): DurableObjectClass { @@ -182,6 +182,7 @@ class Harness { readonly ctx: IoContext; readonly facets = new FakeFacetManager(); readonly storage: DurableObjectStorage; + readonly webSockets: HibernatableWebSocketRegistry; readonly globals: ActorGlobalScope; readonly state: DurableObjectState; readonly scheduled: Array = []; @@ -208,7 +209,12 @@ class Harness { this.ctx, wrapCache === undefined ? this.cache : wrapCache(this.cache), ); - this.globals = new ActorGlobalScope(this.ctx); + this.webSockets = new HibernatableWebSocketRegistry(this.ctx, { + message(): void {}, + close(): void {}, + error(): void {}, + }); + this.globals = new ActorGlobalScope(this.ctx, { webSockets: this.webSockets }); this.state = new DurableObjectState(this.ctx, { id: new TestId("test-actor"), exports: { Thing: class {} }, @@ -216,6 +222,7 @@ class Harness { storage: this.storage, facets: this.facets, globals: actorScopeBindings(() => this.globals), + webSockets: this.webSockets, }); // Nothing else takes it, and an unobserved break would surface as an unhandled rejection. void this.gate.onBroken().catch(() => {}); @@ -805,6 +812,7 @@ test("an actor with no facet manager says so", async () => { props: undefined, storage: h.storage, globals: actorScopeBindings(() => h.globals), + webSockets: h.webSockets, }); await h.run(() => { expect(() => state.facets.get("child", () => ({ class: testClass() }))).toThrow( @@ -995,27 +1003,6 @@ test("blockConcurrencyWhile outside a gated slice throws", async () => { ); }); -// ======================================================================================= -// Hibernatable WebSockets — the substrate boundary, asserted rather than skipped - -apiTest("every hibernatable WebSocket method throws the named message", ({ state }) => { - const socket = {} as WebSocket; - expect(() => state.acceptWebSocket(socket)).toThrow(HIBERNATION_UNIMPLEMENTED_MESSAGE); - expect(() => state.getWebSockets()).toThrow(HIBERNATION_UNIMPLEMENTED_MESSAGE); - expect(() => state.setWebSocketAutoResponse()).toThrow(HIBERNATION_UNIMPLEMENTED_MESSAGE); - expect(() => state.getWebSocketAutoResponse()).toThrow(HIBERNATION_UNIMPLEMENTED_MESSAGE); - expect(() => state.getWebSocketAutoResponseTimestamp(socket)).toThrow( - HIBERNATION_UNIMPLEMENTED_MESSAGE, - ); - expect(() => state.setHibernatableWebSocketEventTimeout(1)).toThrow( - HIBERNATION_UNIMPLEMENTED_MESSAGE, - ); - expect(() => state.getHibernatableWebSocketEventTimeout()).toThrow( - HIBERNATION_UNIMPLEMENTED_MESSAGE, - ); - expect(() => state.getTags(socket)).toThrow(HIBERNATION_UNIMPLEMENTED_MESSAGE); -}); - // ======================================================================================= // DurableObjectState — the rest diff --git a/src/api/actor-state.ts b/src/api/actor-state.ts index 67e8e22..543d680 100644 --- a/src/api/actor-state.ts +++ b/src/api/actor-state.ts @@ -36,11 +36,9 @@ * `transformMaybeBackpressure` keeps the branch because * `DeleteAllResults.backpressure` is still a promise in `io/actor-cache.ts`. * - * Not ported, because the substrate has no equivalent: Hibernatable WebSockets, - * which is the whole reason `DurableObjectState`'s eight WebSocket methods are - * named throwing stubs; V8's private wire bytes, replaced by a browser-safe - * structured-clone encoding with the same public value semantics; the billing - * counters + * Not ported, because the substrate has no equivalent: V8's private wire bytes, + * replaced by a browser-safe structured-clone encoding with the same public + * value semantics; the billing counters * (`billingUnits`, `ActorObserver`, `updateStorageWriteUnit`) and the trace * spans, both already absent throughout; `enableSql`, a workerd namespace option * that exists to simulate a non-SQLite Durable Object; and `ReplicaActorOutgoingFactory`, @@ -71,6 +69,7 @@ import { DurableObjectClass } from "./actor"; import { LoopbackColoLocalActorNamespace, LoopbackDurableObjectNamespace } from "./export-loopback"; import type { ActorScopeBindings } from "./global-scope"; import { SqlStorage } from "./sql"; +import type { HibernatableWebSocketRegistry } from "./web-socket"; // ======================================================================================= // Constants @@ -85,18 +84,6 @@ export const FACET_NAME_MAX_LENGTH = 256; /** Root is at depth 0, so the deepest allowed facet is at depth 3. */ export const FACET_TREE_MAX_DEPTH = 4; -/** - * The substrate boundary named in the package README: Hibernatable WebSockets - * exist so the platform can evict an actor while keeping its sockets open, and - * Chrome exposes no equivalent lifecycle. Under this repo's fail-closed tenet - * the throw IS the specified behaviour, which is why §2.5 orders the four - * silent no-op stubs beside it replaced. - */ -export const HIBERNATION_UNIMPLEMENTED_MESSAGE = - "Hibernatable WebSockets are not available in this runtime: they exist so the platform can " + - "evict a Durable Object while keeping its sockets open, and there is no equivalent lifecycle " + - "to be faithful to."; - /** * ← what falls off the end of `DurableObjectFacets::get`'s class switch * (`actor-state.c++:1029-1043`). @@ -175,7 +162,7 @@ const VALUE_CODEC_HEADER = new Uint8Array([0, 0x44, 0x4f, 1]); * The short header keeps the new representation unambiguous while old JSON rows * remain readable. */ -function serializeValue(value: unknown): Uint8Array { +export function serializeValue(value: unknown): Uint8Array { const body = textEncoder.encode(JSON.stringify(serializeStructuredClone(value))); const encoded = new Uint8Array(VALUE_CODEC_HEADER.byteLength + body.byteLength); encoded.set(VALUE_CODEC_HEADER); @@ -192,7 +179,7 @@ function serializeValue(value: unknown): Uint8Array { * type of the value, but not its contents)". Our four-byte header carries only * a marker and version for the same reason. */ -function deserializeValue(key: string, buffer: Uint8Array): T { +export function deserializeValue(key: string, buffer: Uint8Array): T { if (buffer.byteLength === 0) { throw new Error(`unexpectedly empty value buffer; key = ${key}`); } @@ -1009,6 +996,7 @@ export type DurableObjectStateOptions = { * `DurableObjectState.globals`. */ globals: ActorScopeBindings; + webSockets: HibernatableWebSocketRegistry; }; /** The type passed as the first parameter to a Durable Object class's constructor. */ @@ -1147,39 +1135,37 @@ export class DurableObjectState implements globalThis.DurableObjectState { ); } - // ----------------------------------------------------------------- - // Hibernatable WebSockets — the substrate boundary. §2.5 orders the silent - // no-op stubs replaced with throws, so all eight throw the same named message. - - acceptWebSocket(_ws: WebSocket, _tags?: string[]): never { - throw new Error(HIBERNATION_UNIMPLEMENTED_MESSAGE); + acceptWebSocket(ws: WebSocket, tags?: string[]): void { + this.#options.webSockets.acceptWebSocket(ws, tags); } - getWebSockets(_tag?: string): never { - throw new Error(HIBERNATION_UNIMPLEMENTED_MESSAGE); + getWebSockets(tag?: string): WebSocket[] { + return tag === undefined + ? this.#options.webSockets.getWebSockets() + : this.#options.webSockets.getWebSockets(tag); } - setWebSocketAutoResponse(_maybeReqResp?: WebSocketRequestResponsePair): never { - throw new Error(HIBERNATION_UNIMPLEMENTED_MESSAGE); + setWebSocketAutoResponse(maybeReqResp?: WebSocketRequestResponsePair): void { + this.#options.webSockets.setWebSocketAutoResponse(maybeReqResp); } - getWebSocketAutoResponse(): never { - throw new Error(HIBERNATION_UNIMPLEMENTED_MESSAGE); + getWebSocketAutoResponse(): WebSocketRequestResponsePair | null { + return this.#options.webSockets.getWebSocketAutoResponse(); } - getWebSocketAutoResponseTimestamp(_ws: WebSocket): never { - throw new Error(HIBERNATION_UNIMPLEMENTED_MESSAGE); + getWebSocketAutoResponseTimestamp(ws: WebSocket): Date | null { + return this.#options.webSockets.getWebSocketAutoResponseTimestamp(ws); } - setHibernatableWebSocketEventTimeout(_timeoutMs?: number): never { - throw new Error(HIBERNATION_UNIMPLEMENTED_MESSAGE); + setHibernatableWebSocketEventTimeout(timeoutMs?: number): void { + this.#options.webSockets.setHibernatableWebSocketEventTimeout(timeoutMs); } - getHibernatableWebSocketEventTimeout(): never { - throw new Error(HIBERNATION_UNIMPLEMENTED_MESSAGE); + getHibernatableWebSocketEventTimeout(): number | null { + return this.#options.webSockets.getHibernatableWebSocketEventTimeout(); } - getTags(_ws: WebSocket): never { - throw new Error(HIBERNATION_UNIMPLEMENTED_MESSAGE); + getTags(ws: WebSocket): string[] { + return this.#options.webSockets.getTags(ws); } } diff --git a/src/api/global-scope.test.ts b/src/api/global-scope.test.ts index ae53da2..821cbb6 100644 --- a/src/api/global-scope.test.ts +++ b/src/api/global-scope.test.ts @@ -24,6 +24,7 @@ import { isAlarmFailureUserError, NO_GLOBAL_OUTBOUND_MESSAGE, } from "./global-scope"; +import { HibernatableWebSocketRegistry } from "./web-socket"; describe("AlarmInvocationInfo", () => { test("carries the scheduled time and the retry count", () => { @@ -145,14 +146,23 @@ async function quiesce(turns = 8): Promise { } } -function newScope(options?: ActorGlobalScopeOptions): { +function newScope(options: Omit = {}): { ctx: IoContext; timer: TestTimer; scope: ActorGlobalScope; } { const timer = new TestTimer(); const ctx = new IoContext(new TestActor(), timer); - return { ctx, timer, scope: new ActorGlobalScope(ctx, options) }; + const webSockets = new HibernatableWebSocketRegistry(ctx, { + message(): void {}, + close(): void {}, + error(): void {}, + }); + return { + ctx, + timer, + scope: new ActorGlobalScope(ctx, { ...options, webSockets }), + }; } describe("Scheduler", () => { @@ -396,7 +406,7 @@ describe("installActorScope", () => { expect(bound.currentExternalEntry).toBe(currentExternalEntry); }); - test("writes all seven names onto a scope object, bound", async () => { + test("writes all actor globals onto a scope object, bound", async () => { // Bound, because a dynamically-loaded Worker source destructures them: `const // { scheduler, setTimeout } = …` would lose `this` on a method. const { ctx, timer, scope } = newScope({ fetch: async () => new Response("ok") }); @@ -404,6 +414,9 @@ describe("installActorScope", () => { installActorScope(target, () => scope); expect(Object.keys(target).sort()).toEqual([ + "WebSocket", + "WebSocketPair", + "WebSocketRequestResponsePair", "clearInterval", "clearTimeout", "crypto", diff --git a/src/api/global-scope.ts b/src/api/global-scope.ts index 081dcf6..795f1e5 100644 --- a/src/api/global-scope.ts +++ b/src/api/global-scope.ts @@ -49,6 +49,13 @@ import { type IoContext, } from "../io/io-context"; import { gateResponseBody } from "./http"; +import { + installWebSocketGlobals, + WebSocketRequestResponsePair, + type HibernatableWebSocketRegistry, +} from "./web-socket"; + +type WebSocketPairConstructor = typeof WebSocketPair; /** * ← `AlarmInvocationInfo` (`api/global-scope.h:386-412`): "a jsg::Object used to @@ -288,6 +295,7 @@ function abortReasonOf(signal: AbortSignal | undefined): unknown { export type FetchPort = (input: RequestInfo | URL, init?: RequestInit) => Promise; export type ActorGlobalScopeOptions = { + readonly webSockets: HibernatableWebSocketRegistry; /** Opaque identity of the external entry whose synchronous body is running. */ readonly currentExternalEntry?: (() => object | undefined) | undefined; /** @@ -335,11 +343,15 @@ export class ActorGlobalScope { readonly #readCurrentExternalEntry: (() => object | undefined) | undefined; readonly scheduler: Scheduler; readonly crypto: GatedCrypto; + declare readonly WebSocket: typeof globalThis.WebSocket; + declare readonly WebSocketPair: WebSocketPairConstructor; + declare readonly WebSocketRequestResponsePair: typeof WebSocketRequestResponsePair; - constructor(ctx: IoContext, options: ActorGlobalScopeOptions = {}) { + constructor(ctx: IoContext, options: ActorGlobalScopeOptions) { this.#ctx = ctx; this.#fetch = options.fetch; this.#readCurrentExternalEntry = options.currentExternalEntry; + installWebSocketGlobals(this, options.webSockets.WebSocketPair); this.scheduler = new Scheduler(this); this.crypto = new GatedCrypto( (op) => { @@ -489,6 +501,9 @@ export type ActorScopeBindings = { readonly clearInterval: (id?: number | null) => void; readonly fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; readonly crypto: Crypto; + readonly WebSocket: typeof globalThis.WebSocket; + readonly WebSocketPair: WebSocketPairConstructor; + readonly WebSocketRequestResponsePair: typeof WebSocketRequestResponsePair; readonly currentExternalEntry?: object | undefined; }; @@ -502,6 +517,13 @@ export type ActorScopeBindings = { * scope instead. A single-actor host simply writes `() => scope`. */ export function actorScopeBindings(resolve: () => ActorGlobalScope): ActorScopeBindings { + const BoundWebSocketPair: WebSocketPairConstructor = new Proxy( + class WebSocketPair { + declare readonly 0: WebSocket; + declare readonly 1: WebSocket; + }, + { construct: () => new (resolve().WebSocketPair)() }, + ); return { awaitIo: (promise) => resolve().awaitIo(promise), scheduler: { @@ -518,6 +540,9 @@ export function actorScopeBindings(resolve: () => ActorGlobalScope): ActorScopeB }, fetch: (input, init) => resolve().fetch(input, init), crypto: scopeCrypto(resolve), + WebSocket: globalThis.WebSocket, + WebSocketPair: BoundWebSocketPair, + WebSocketRequestResponsePair, get currentExternalEntry(): object | undefined { return resolve().currentExternalEntry; }, @@ -529,7 +554,7 @@ export function actorScopeBindings(resolve: () => ActorGlobalScope): ActorScopeB * an operation actually runs. * * That laziness is required rather than tidy, and both lanes proved it. A facet's - * module destructures its seven names at module scope, which is BEFORE its container + * module destructures its actor globals at module scope, which is BEFORE its container * exists — so a `crypto` that resolved on read threw at import. And on the root * path `globalThis.crypto` is read by things that are not the actor at all: capnweb, * the sqlite driver, the test runner. So the binding is a pair of plain objects @@ -581,8 +606,8 @@ const ASYNC_SUBTLE_METHODS = [ * one that is not. * * **A host should call this rather than assigning the names itself**, and the - * reason is the failure it prevents: a host that installs five of the six leaves - * one primitive ungated, and an ungated primitive that WORKS is invisible until + * reason is the failure it prevents: a host that installs only a subset leaves a + * primitive ungated, and an ungated primitive that WORKS is invisible until * a continuation after it touches storage — possibly never, on the path that * matters. The set is the package's, so it can grow without every host growing * with it. diff --git a/src/api/hibernatable-web-socket.test.ts b/src/api/hibernatable-web-socket.test.ts new file mode 100644 index 0000000..eb768a4 --- /dev/null +++ b/src/api/hibernatable-web-socket.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, test, vi } from "vitest"; +import { createNodeSqlProvider } from "../../backends/node-sqlite"; +import type { Timer } from "../io/io-context"; +import type { InputGateHooks, OutputGateHooks } from "../io/io-gate"; +import { + createActorContainer, + noFacets, + type ActorContainer, + type ActorContainerOptions, + type HibernationHost, +} from "../server/actor-container"; +import type { RawWebSocket } from "./web-socket"; + +const timer: Timer = { + now: () => Date.now(), + afterDelay: (ms, signal) => + new Promise((resolve, reject) => { + const handle = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { + clearTimeout(handle); + reject(signal.reason); + }); + }), +}; + +const alarms = { scheduleRun: (): Promise => Promise.resolve() }; + +function options(overrides: Partial = {}): ActorContainerOptions { + return { + id: "socket-actor", + uniqueKey: "hibernatable-web-socket-test", + exports: {}, + env: {}, + ports: { + sql: createNodeSqlProvider(), + alarms, + facets: noFacets, + timer, + }, + ...overrides, + }; +} + +async function quiesce(turns = 8): Promise { + for (let turn = 0; turn < turns; turn++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +type SocketLike = WebSocket & { + serializeAttachment(value: unknown): void; + deserializeAttachment(): unknown; +}; + +class RawEndpoint extends EventTarget implements RawWebSocket, WebSocket { + readonly READY_STATE_CONNECTING = 0; + readonly READY_STATE_OPEN = 1; + readonly READY_STATE_CLOSING = 2; + readonly READY_STATE_CLOSED = 3; + readonly CONNECTING = 0; + readonly OPEN = 1; + readonly CLOSING = 2; + readonly CLOSED = 3; + readonly bufferedAmount = 0; + readonly extensions = ""; + readonly protocol = ""; + readonly readyState = 1; + readonly url = ""; + binaryType: "blob" | "arraybuffer" = "blob"; + onopen: WebSocket["onopen"] = null; + onmessage: WebSocket["onmessage"] = null; + onclose: WebSocket["onclose"] = null; + onerror: WebSocket["onerror"] = null; + peer!: RawEndpoint; + + accept(): void {} + + send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void { + this.peer.dispatchEvent(new MessageEvent("message", { data })); + } + + close(code = 1000, reason = ""): void { + this.peer.dispatchEvent(new CloseEvent("close", { code, reason, wasClean: true })); + } + + serializeAttachment(): void {} + + deserializeAttachment(): null { + return null; + } +} + +function rawPair(): [client: RawEndpoint, server: RawEndpoint] { + const client = new RawEndpoint(); + const server = new RawEndpoint(); + client.peer = server; + server.peer = client; + return [client, server]; +} + +class SocketActor { + readonly messages: Record[] = []; + readonly constructorSockets: Record[]; + + constructor(readonly ctx: DurableObjectState) { + this.constructorSockets = ctx.getWebSockets().map((socket) => ({ + tags: ctx.getTags(socket), + attachment: (socket as SocketLike).deserializeAttachment(), + })); + } + + webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): void { + this.messages.push({ + message, + tags: this.ctx.getTags(socket), + attachment: (socket as SocketLike).deserializeAttachment(), + listed: this.ctx.getWebSockets().includes(socket), + }); + socket.send("ack"); + } + + webSocketClose(socket: WebSocket, code: number, reason: string): void { + socket.close(code, reason); + } +} + +class RawSocketActor { + readonly messages: (string | ArrayBuffer)[] = []; + + constructor(readonly ctx: DurableObjectState) {} + + webSocketMessage(_socket: WebSocket, message: string | ArrayBuffer): void { + this.messages.push(message); + } +} + +type MirrorEntry = { + socket: RawWebSocket; + tags: readonly string[]; + attachment?: Uint8Array; + autoResponseTimestamp?: number; +}; + +function recorder(): { + host: HibernationHost; + entries: Map; + accepted: ReturnType; + attached: ReturnType; + autoResponse: ReturnType; + closed: ReturnType; +} { + const entries = new Map(); + const accepted = vi.fn((socket: RawWebSocket, tags: readonly string[]) => { + entries.set(socket, { socket, tags: [...tags] }); + }); + const attached = vi.fn((socket: RawWebSocket, bytes: Uint8Array | null) => { + const entry = entries.get(socket); + if (entry === undefined) throw new Error("attachment mirrored before acceptance"); + if (bytes === null) delete entry.attachment; + else entry.attachment = bytes.slice(); + }); + const autoResponse = vi.fn(); + const closed = vi.fn((socket: RawWebSocket) => { + entries.delete(socket); + }); + return { + entries, + accepted, + attached, + autoResponse, + closed, + host: { accepted, attachment: attached, autoResponse, closed }, + }; +} + +async function started( + overrides: Partial = {}, +): Promise<{ container: ActorContainer; actor: SocketActor }> { + const container = await createActorContainer(options(overrides)); + const actor = await container.start((ctx) => new SocketActor(ctx)); + return { container, actor }; +} + +describe("hibernation embedder contract", () => { + test("mirrors acceptance and attachment bytes, then rebuilds before the constructor", async () => { + const mirror = recorder(); + const first = await started({ + ports: { ...options().ports, hibernation: mirror.host }, + }); + + let client!: SocketLike; + let server!: SocketLike; + await first.container.run(() => { + const pair = new first.container.globals.WebSocketPair(); + client = pair[0] as SocketLike; + server = pair[1] as SocketLike; + first.container.state.acceptWebSocket(server, ["connection-id", "room"]); + server.serializeAttachment({ id: "connection-id", state: { count: 1 } }); + client.accept(); + }); + + expect(mirror.accepted).toHaveBeenCalledWith(server, ["connection-id", "room"]); + expect(mirror.attached.mock.calls[0]?.[0]).toBe(server); + const persisted = mirror.entries.get(server); + expect(persisted?.attachment).toBeInstanceOf(Uint8Array); + if (persisted === undefined) throw new Error("accepted socket was not mirrored"); + + const secondMirror = recorder(); + const second = await started({ + ports: { ...options().ports, hibernation: secondMirror.host }, + webSockets: [persisted], + }); + expect(second.actor.constructorSockets).toEqual([ + { + tags: ["connection-id", "room"], + attachment: { id: "connection-id", state: { count: 1 } }, + }, + ]); + expect(secondMirror.accepted).not.toHaveBeenCalled(); + + client.send("after-rebuild"); + await quiesce(); + expect(second.actor.messages).toEqual([ + { + message: "after-rebuild", + tags: ["connection-id", "room"], + attachment: { id: "connection-id", state: { count: 1 } }, + listed: true, + }, + ]); + }); + + test("mirrors auto-response and close lifecycle with the original socket reference", async () => { + const mirror = recorder(); + const { container, actor } = await started({ + ports: { ...options().ports, hibernation: mirror.host }, + }); + let client!: SocketLike; + let server!: SocketLike; + const replies: string[] = []; + await container.run(() => { + const pair = new container.globals.WebSocketPair(); + client = pair[0] as SocketLike; + server = pair[1] as SocketLike; + container.state.acceptWebSocket(server, ["id"]); + client.accept(); + client.addEventListener("message", (event) => replies.push(String(event.data))); + container.state.setWebSocketAutoResponse( + new container.globals.WebSocketRequestResponsePair("ping", "pong") as WebSocketRequestResponsePair, + ); + }); + + expect(mirror.autoResponse).toHaveBeenLastCalledWith({ request: "ping", response: "pong" }); + client.send("ping"); + await quiesce(); + expect(replies).toEqual(["pong"]); + expect(actor.messages).toEqual([]); + expect(container.state.getWebSocketAutoResponseTimestamp(server)).toBeInstanceOf(Date); + + client.close(4001, "bye"); + await quiesce(); + expect(mirror.closed).toHaveBeenCalledWith(server); + + expect(() => server.serializeAttachment({ after: "close" })).not.toThrow(); + expect(server.deserializeAttachment()).toEqual({ after: "close" }); + expect(mirror.entries.has(server)).toBe(false); + }); + + test("moves a rehydrated raw socket's listener to the replacement registry", async () => { + const mirror = recorder(); + const firstContainer = await createActorContainer( + options({ ports: { ...options().ports, hibernation: mirror.host } }), + ); + const firstActor = await firstContainer.start((ctx) => new RawSocketActor(ctx)); + const [client, server] = rawPair(); + + await firstContainer.run(() => { + firstContainer.state.acceptWebSocket(server, ["raw"]); + }); + const persisted = mirror.entries.get(server); + if (persisted === undefined) throw new Error("accepted raw socket was not mirrored"); + + const secondContainer = await createActorContainer( + options({ + ports: { ...options().ports, hibernation: mirror.host }, + webSockets: [persisted], + }), + ); + const secondActor = await secondContainer.start((ctx) => new RawSocketActor(ctx)); + + client.send("after-rebuild"); + await quiesce(); + expect(firstActor.messages).toEqual([]); + expect(secondActor.messages).toEqual(["after-rebuild"]); + }); + + test("pins cross-accept, attachment, and synchronous close errors", async () => { + const { container } = await started(); + await container.run(() => { + const hibernatable = new container.globals.WebSocketPair(); + container.state.acceptWebSocket(hibernatable[1]); + expect(() => container.state.acceptWebSocket(hibernatable[1])).toThrowError( + new Error("Cannot call `acceptWebSocket()` if the WebSocket was already accepted via `accept()`"), + ); + expect(() => hibernatable[1].accept()).toThrowError( + new TypeError("Can't accept() WebSocket after enabling hibernation."), + ); + + const classic = new container.globals.WebSocketPair(); + classic[1].accept(); + expect(() => container.state.acceptWebSocket(classic[1])).toThrowError( + new Error("Cannot call `acceptWebSocket()` if the WebSocket was already accepted via `accept()`"), + ); + + const attachment = { nested: { value: 1 } }; + (hibernatable[1] as SocketLike).serializeAttachment(attachment); + attachment.nested.value = 2; + expect((hibernatable[1] as SocketLike).deserializeAttachment()).toEqual({ + nested: { value: 1 }, + }); + expect(() => + (hibernatable[1] as SocketLike).serializeAttachment("x".repeat(16_380)), + ).toThrowError( + new Error( + "A WebSocket 'attachment' cannot be larger than 16384 bytes.'attachment' was 16385 bytes.", + ), + ); + + hibernatable[1].close(4000, "done"); + expect(() => hibernatable[1].send("late")).toThrowError( + new TypeError("Can't call WebSocket send() after close()."), + ); + }); + }); +}); + +test("quiescence reports eviction state and gateHooks reach both gates", async () => { + const inputTrace: string[] = []; + const outputTrace: string[] = []; + const input: InputGateHooks = { + inputGateLocked: () => inputTrace.push("locked"), + inputGateReleased: () => inputTrace.push("released"), + inputGateWaiterAdded: () => inputTrace.push("waiter-added"), + inputGateWaiterRemoved: () => inputTrace.push("waiter-removed"), + }; + const output: OutputGateHooks = { + makeTimeoutPromise: () => new Promise(() => {}), + outputGateLocked: () => outputTrace.push("locked"), + outputGateReleased: () => outputTrace.push("released"), + outputGateWaiterAdded: () => outputTrace.push("waiter-added"), + outputGateWaiterRemoved: () => outputTrace.push("waiter-removed"), + }; + const { container } = await started({ gateHooks: { input, output } }); + const pending = Promise.withResolvers(); + let interval = 0; + + const inside = await container.run(() => { + container.state.waitUntil(pending.promise); + interval = container.globals.setInterval(() => {}, 60_000); + void container.state.storage.put("output-hook", 1); + return container.quiescence(); + }); + expect(inside).toEqual({ + armedTimers: 1, + pendingWaitUntil: 2, + inputLockHeld: true, + outputGateBroken: false, + }); + await quiesce(2); + expect(container.quiescence()).toEqual({ + armedTimers: 1, + pendingWaitUntil: 2, + inputLockHeld: false, + outputGateBroken: false, + }); + + pending.resolve(); + await container.run(() => container.globals.clearInterval(interval)); + await container.drainWaitUntil(); + await quiesce(2); + expect(container.quiescence()).toEqual({ + armedTimers: 0, + pendingWaitUntil: 0, + inputLockHeld: false, + outputGateBroken: false, + }); + expect(inputTrace).toContain("locked"); + expect(inputTrace).toContain("released"); + expect(outputTrace).toContain("locked"); + expect(outputTrace).toContain("released"); +}); diff --git a/src/api/web-socket.ts b/src/api/web-socket.ts index 24e0e0b..9415e4b 100644 --- a/src/api/web-socket.ts +++ b/src/api/web-socket.ts @@ -1,216 +1,782 @@ /** - * ← workerd `src/workerd/api/web-socket.{h,c++}` — the gating, and nothing else. + * ← workerd `src/workerd/api/web-socket.{h,c++}` — classic and hibernatable + * socket delivery over the actor's input/output gates. * - * A socket is the one primitive that is neither of the other two, and §1.8 says - * why in three lines: incoming frames "each take a fresh input lock via - * `context.run(...)`", the read loop "captures the critical section at - * `accept()` time", and outbound messages "each carry their own output-gate - * promise captured at `send()` time". Upstream states the first outright, on the - * line that does it (`web-socket.c++:1056-1059`): - * - * > "Re-enter the context with context.run(). This is arguably a bit unusual - * > compared to other I/O which is delivered by return from context.awaitIo(), - * > but the difference here is that we have a long stream of events over time. - * > It makes sense to use context.run() each time a new event arrives." - * - * So a socket cannot be `awaitIo`: there is no single result to resume from. - * `accept()` starts a loop, and the loop is the gate's caller. - * - * **What is ported and what is not.** The frame protocol, the hibernation - * states, auto-response, `WebSocketPair` and the byte accounting are all - * absent — the substrate ships a `WebSocket`, and hibernation is a recorded - * substrate boundary with no Chrome lifecycle to be faithful to. What is here is - * `WebSocket::Accepted`: the three gate properties above, over whatever socket - * the host hands in. That is the same division `api/http.ts` makes and for the - * same reason. - * - * **The accept contract, and the hole it leaves.** After `acceptWebSocket`, the - * gated view owns the raw socket's events. A consumer that keeps a reference to - * the raw socket and registers a listener on it directly gets that listener - * called ungated, and nothing here can prevent it — upstream cannot be reached - * that way because `accept()` moves the `kj::WebSocket` into `Accepted` and the - * JS object never had it. The refusal below covers the case that is detectable - * (accepting the same socket twice); the rest is the accept contract, stated. - * - * Spec: §1.1, §1.8 and decision 5 in - * docs/decisions.md. + * The runtime owns the WebSocket object produced by `WebSocketPair`; embedders + * still own network transports supplied as `RawWebSocket`s. Hibernation state is + * per container and can be mirrored through `HibernationHost` for reconstruction. */ import type { IoContext } from "../io/io-context"; import type { CriticalSection } from "../io/io-gate"; +import { deserializeValue, serializeValue } from "./actor-state"; -/** - * The socket beneath. Deliberately structural and minimal: a real `WebSocket`, - * the extension's `WebSocketFacade` over capnweb, and a test double all satisfy - * it, and none of them is a type this package should name. - */ export interface RawWebSocket { addEventListener(type: string, listener: (event: Event) => void): void; send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void; close(code?: number, reason?: string): void; + readonly readyState?: number; + binaryType?: string; } +export interface HibernationHost { + accepted(socket: RawWebSocket, tags: readonly string[]): void; + attachment(socket: RawWebSocket, bytes: Uint8Array | null): void; + autoResponse(pair: { request: string; response: string } | null): void; + closed(socket: RawWebSocket): void; +} + +export type RehydratedWebSocket = { + socket: RawWebSocket; + tags?: readonly string[]; + attachment?: Uint8Array; + autoResponseTimestamp?: number; +}; + +type WebSocketPairConstructor = typeof WebSocketPair; + +class WebSocketRequestResponsePairImpl implements WebSocketRequestResponsePair { + readonly #request: string; + readonly #response: string; + + constructor(request: string, response: string) { + this.#request = String(request); + this.#response = String(response); + } + + get request(): string { + return this.#request; + } + + get response(): string { + return this.#response; + } +} + +const RuntimeWebSocketRequestResponsePair: typeof WebSocketRequestResponsePair = + new Proxy(WebSocketRequestResponsePairImpl, { + apply(): never { + throw new TypeError( + "Failed to construct 'WebSocketRequestResponsePair': Please use the 'new' operator, this DOM object constructor cannot be called as a function.", + ); + }, + }); + +export { RuntimeWebSocketRequestResponsePair as WebSocketRequestResponsePair }; + +type RuntimeWebSocketPair = { + 0: AcceptedWebSocket; + 1: AcceptedWebSocket; +}; + /** ← the `JSG_REQUIRE(!native.state.is(), ...)` at the head of `accept()`. */ export const ALREADY_ACCEPTED_MESSAGE = "acceptWebSocket(): this socket has already been accepted by an actor. A socket's frames are " + "delivered by exactly one read loop, and a second accept would deliver them under two gates."; -/** Sockets this runtime has accepted, so the refusal above is answerable. */ -const accepted = new WeakSet(); +export const HIBERNATION_ALREADY_ACCEPTED_MESSAGE = + "Cannot call `acceptWebSocket()` if the WebSocket was already accepted via `accept()`"; +export const HIBERNATION_AFTER_ACCEPT_MESSAGE = + "Can't accept() WebSocket after enabling hibernation."; +export const HIBERNATION_PAIR_USED_MESSAGE = + "Cannot call `acceptWebSocket()` on this WebSocket because its pair has already been accepted or used in a Response."; + +const MAX_HIBERNATABLE_SOCKETS = 32_768; +const MAX_TAGS = 10; +const MAX_TAG_LENGTH = 256; +const MAX_ATTACHMENT_BYTES = 16_384; +const MAX_AUTO_RESPONSE_BYTES = 2_048; +const MAX_EVENT_TIMEOUT = 604_800_000; +const MAX_CLOSE_REASON_BYTES = 123; + +const WEB_SOCKET_READY_STATES = { + READY_STATE_CONNECTING: 0, + READY_STATE_OPEN: 1, + READY_STATE_CLOSING: 2, + READY_STATE_CLOSED: 3, + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3, +} as const; + +const textEncoder = new TextEncoder(); -/** The four events a `WebSocket` dispatches, which `readLoop` and its `.then` cover upstream. */ const SOCKET_EVENTS = ["open", "message", "close", "error"] as const; type SocketEvent = (typeof SOCKET_EVENTS)[number]; -/** - * ← `WebSocket::Accepted` (`web-socket.h:~300-360`), reached through - * `accept()` → `internalAccept(js, IoContext::current().getCriticalSection())` - * → `startReadLoop` (`web-socket.c++:133`, `:426`, `:429-433`, `:507`). - * - * An `EventTarget`, so a consumer registers listeners the way it would on a real - * socket — but on THIS object rather than on the raw one, because this is what - * runs them inside a gated slice. - */ -export class AcceptedWebSocket extends EventTarget { - readonly #ctx: IoContext; +type PairState = { + used: boolean; + hibernationAccepted: boolean; +}; + +type SocketAcceptance = + | { mode: "classic" } + | { mode: "hibernatable"; registry: HibernatableWebSocketRegistry }; + +type SocketDelivery = + | { mode: "pending" } + | { mode: "classic"; criticalSection: CriticalSection | undefined } + | { mode: "hibernatable"; registry: HibernatableWebSocketRegistry }; + +type SocketMetadata = { + accepted?: SocketAcceptance; + attachment?: Uint8Array; + rawListenersInstalled?: true; +}; + +const metadata = new WeakMap(); + +function socketMetadata(socket: object): SocketMetadata { + let value = metadata.get(socket); + if (value === undefined) { + value = {}; + metadata.set(socket, value); + } + return value; +} + +function isRawWebSocket(value: unknown): value is RawWebSocket { + return ( + typeof value === "object" && + value !== null && + "addEventListener" in value && + typeof value.addEventListener === "function" && + "send" in value && + typeof value.send === "function" && + "close" in value && + typeof value.close === "function" + ); +} + +function requireWebSocket(value: unknown, operation: string): RawWebSocket { + if (!isRawWebSocket(value)) { + throw new TypeError( + `Failed to execute '${operation}' on 'WebSocket': parameter 1 is not of type 'WebSocket'.`, + ); + } + return value; +} + +function serializeAttachment(socket: RawWebSocket, value: unknown): void { + try { + structuredClone(value); + } catch (error) { + if (error instanceof DOMException && error.name === "DataCloneError") { + throw new DOMException( + error.message.replace(/^Failed to execute 'structuredClone' on '[^']+': /, ""), + "DataCloneError", + ); + } + throw error; + } + const bytes = serializeValue(value); + // ponytail: the local codec's string envelope is seven bytes wider than V8's; + // replace this size adapter if the package adopts V8 wire bytes. + const measuredBytes = + typeof value === "string" ? textEncoder.encode(value).byteLength + 5 : bytes.byteLength; + if (measuredBytes > MAX_ATTACHMENT_BYTES) { + throw new Error( + `A WebSocket 'attachment' cannot be larger than ${MAX_ATTACHMENT_BYTES} bytes.'attachment' was ${measuredBytes} bytes.`, + ); + } + const state = socketMetadata(socket); + state.attachment = bytes; + if (state.accepted?.mode === "hibernatable") { + state.accepted.registry.attachmentChanged(socket, bytes); + } +} + +function deserializeAttachment(socket: RawWebSocket): unknown { + const attachment = socketMetadata(socket).attachment; + if (attachment === undefined) return null; + return deserializeValue("WebSocket attachment", attachment); +} + +function serializeAttachmentMethod(this: unknown, value?: unknown): void { + if (arguments.length === 0) { + throw new TypeError( + "Failed to execute 'serializeAttachment' on 'WebSocket': parameter 1 is not of type 'Value'.", + ); + } + serializeAttachment(requireWebSocket(this, "serializeAttachment"), value); +} + +function deserializeAttachmentMethod(this: unknown): unknown { + return deserializeAttachment(requireWebSocket(this, "deserializeAttachment")); +} + +function cloneMessageData(data: unknown): string | ArrayBuffer | Blob { + if (typeof data === "string" || data instanceof Blob) return data; + if (data instanceof ArrayBuffer) return data.slice(0); + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice().buffer; + } + return String(data); +} + +class MemoryWebSocketEndpoint extends EventTarget implements RawWebSocket { + peer!: MemoryWebSocketEndpoint; + #sentClose = false; + + send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void { + this.peer.dispatchEvent(new MessageEvent("message", { data: cloneMessageData(data) })); + } + + close(code = 1000, reason = ""): void { + if (this.#sentClose) return; + this.#sentClose = true; + this.peer.dispatchEvent(new CloseEvent("close", { code, reason, wasClean: true })); + } +} + +/** One public socket identity, in classic or hibernatable mode after acceptance. */ +export class AcceptedWebSocket extends EventTarget implements RawWebSocket, WebSocket { + static readonly READY_STATE_CONNECTING = WEB_SOCKET_READY_STATES.READY_STATE_CONNECTING; + static readonly READY_STATE_OPEN = WEB_SOCKET_READY_STATES.READY_STATE_OPEN; + static readonly READY_STATE_CLOSING = WEB_SOCKET_READY_STATES.READY_STATE_CLOSING; + static readonly READY_STATE_CLOSED = WEB_SOCKET_READY_STATES.READY_STATE_CLOSED; + static readonly CONNECTING = WEB_SOCKET_READY_STATES.CONNECTING; + static readonly OPEN = WEB_SOCKET_READY_STATES.OPEN; + static readonly CLOSING = WEB_SOCKET_READY_STATES.CLOSING; + static readonly CLOSED = WEB_SOCKET_READY_STATES.CLOSED; + + declare readonly READY_STATE_CONNECTING: 0; + declare readonly READY_STATE_OPEN: 1; + declare readonly READY_STATE_CLOSING: 2; + declare readonly READY_STATE_CLOSED: 3; + declare readonly CONNECTING: 0; + declare readonly OPEN: 1; + declare readonly CLOSING: 2; + declare readonly CLOSED: 3; + readonly bufferedAmount = 0; + readonly extensions = ""; + readonly protocol = ""; + readonly url = ""; + + #ctx: IoContext; readonly #socket: RawWebSocket; - /** - * ← `readLoop`'s `cs` parameter, captured at accept and replayed for every - * frame via `mapAddRef(cs)` (`web-socket.c++:1110`). A socket accepted inside - * `blockConcurrencyWhile` therefore delivers its messages inside that critical - * section — §1.8's second bullet, and the reason this is captured here rather - * than read when a frame arrives. - */ - readonly #criticalSection: CriticalSection | undefined; - - /** - * ← `OutgoingMessagesMap outgoingMessages` plus `ensurePumping` - * (`web-socket.h:582-590`, `web-socket.c++:948-975`), as a chain. - * - * The table is insertion-ordered and the pump awaits each entry's own - * `outputLock` before sending it, so messages leave in order and message N - * waits only for the writes outstanding when IT was enqueued. A promise chain - * is the same two properties with nothing to schedule. - */ + readonly #pairState: PairState | undefined; + #delivery: SocketDelivery = { mode: "pending" }; #pump: Promise = Promise.resolve(); + #pending: { type: SocketEvent; event: Event }[] = []; + #readyState: number = AcceptedWebSocket.OPEN; + #ownClose = false; + #peerClose = false; + #binaryType: "blob" | "arraybuffer" = "blob"; onopen: ((event: Event) => void) | null = null; onmessage: ((event: MessageEvent) => void) | null = null; onclose: ((event: CloseEvent) => void) | null = null; onerror: ((event: Event) => void) | null = null; - constructor(ctx: IoContext, socket: RawWebSocket) { + constructor(ctx: IoContext, socket: RawWebSocket, pairState?: PairState) { super(); this.#ctx = ctx; this.#socket = socket; - this.#criticalSection = ctx.getCriticalSection(); - - // ← `startReadLoop`. One listener per event type on the raw socket, forever; each delivery is - // one gated run. Upstream's loop is a coroutine over `ws.receive()`, which is the same shape - // an event listener already is here. + this.#pairState = pairState; + if (pairState === undefined) this.#enableClassic(); for (const type of SOCKET_EVENTS) { socket.addEventListener(type, (event: Event) => { - this.#deliver(type, event); + this.#receive(type, event); }); } } - /** - * ← `co_await context.run([...](auto& wLock) { dispatchEventImpl(...) }, mapAddRef(cs))` - * (`web-socket.c++:1065-1110`). - * - * The run rides `addWaitUntil`, as upstream's read loop does ("We put the read - * loop in a `waitUntil`, since there would otherwise be a race condition - * between delivering the final close message and the request being canceled", - * `web-socket.c++:537-541`). That is also what stops a listener's throw - * becoming an unhandled rejection: it lands in `waitUntilStatus()`. - */ - #deliver(type: SocketEvent, event: Event): void { + get readyState(): number { + return this.#readyState; + } + + get binaryType(): "blob" | "arraybuffer" { + return this.#binaryType; + } + + set binaryType(value: "blob" | "arraybuffer") { + this.#binaryType = value; + } + + accept(): void { + if (this.#delivery.mode === "hibernatable") { + throw new TypeError(HIBERNATION_AFTER_ACCEPT_MESSAGE); + } + if (this.#delivery.mode === "classic") throw new Error(ALREADY_ACCEPTED_MESSAGE); + if (this.#pairState !== undefined) this.#pairState.used = true; + this.#enableClassic(); + } + + send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void { + if (this.#delivery.mode === "hibernatable" && this.#ownClose) { + throw new TypeError("Can't call WebSocket send() after close()."); + } + if (this.#peerClose || this.#readyState === AcceptedWebSocket.CLOSED) return; + this.#markPairUsed(); + this.#enqueue(() => this.#socket.send(data)); + } + + close(code?: number, reason = ""): void { + if (this.#readyState === AcceptedWebSocket.CLOSED || this.#ownClose) return; + if (this.#delivery.mode === "hibernatable" || this.#pairState !== undefined) { + validateClose(code, reason); + } + this.#markPairUsed(); + this.#ownClose = true; + this.#readyState = this.#peerClose ? AcceptedWebSocket.CLOSED : AcceptedWebSocket.CLOSING; + this.#enqueue(() => this.#socket.close(code, reason)); + } + + serializeAttachment(value: unknown): void { + if (arguments.length === 0) serializeAttachmentMethod.call(this); + else serializeAttachment(this, value); + } + + deserializeAttachment(): unknown { + return deserializeAttachment(this); + } + + acceptHibernation(registry: HibernatableWebSocketRegistry): void { + if (this.#delivery.mode !== "pending") { + throw new Error(HIBERNATION_ALREADY_ACCEPTED_MESSAGE); + } + if (this.#pairState?.used === true && !this.#pairState.hibernationAccepted) { + throw new Error(HIBERNATION_PAIR_USED_MESSAGE); + } + if (this.#pairState !== undefined) { + this.#pairState.used = true; + this.#pairState.hibernationAccepted = true; + } + this.#activateHibernation(registry, this.#ctx); + } + + rehydrateHibernation(registry: HibernatableWebSocketRegistry, ctx: IoContext): void { + this.#activateHibernation(registry, ctx); + } + + #activateHibernation(registry: HibernatableWebSocketRegistry, ctx: IoContext): void { + this.#ctx = ctx; + this.#delivery = { mode: "hibernatable", registry }; + this.#pending = []; + } + + markPairUsed(): void { + this.#markPairUsed(); + } + + #markPairUsed(): void { + if (this.#pairState !== undefined) this.#pairState.used = true; + } + + #enableClassic(): void { + const delivery: SocketDelivery = { + mode: "classic", + criticalSection: this.#ctx.getCriticalSection(), + }; + this.#delivery = delivery; + socketMetadata(this).accepted = { mode: "classic" }; + const pending = this.#pending; + this.#pending = []; + for (const item of pending) { + this.#deliverClassic(item.type, item.event, delivery.criticalSection); + } + } + + #receive(type: SocketEvent, event: Event): void { + if (type === "close") { + this.#receiveClose(event as CloseEvent); + return; + } + const delivery = this.#delivery; + if (delivery.mode === "pending") this.#pending.push({ type, event }); + else if (delivery.mode === "classic") { + this.#deliverClassic(type, event, delivery.criticalSection); + } else delivery.registry.receive(this, type, event); + } + + #receiveClose(event: CloseEvent): void { + if (this.#ownClose) { + this.#readyState = AcceptedWebSocket.CLOSED; + } else { + this.#peerClose = true; + this.#readyState = AcceptedWebSocket.CLOSING; + if (this.#delivery.mode === "classic" && this.#pairState !== undefined) { + // Pair halves perform the WebSocket close handshake in-memory. An + // embedder-supplied raw socket owns its own protocol and only reports. + if (event.code === 1005 || event.code === 1006 || event.code === 1015) { + this.#readyState = AcceptedWebSocket.CLOSED; + } else { + this.close(event.code, event.reason); + } + } + } + const delivery = this.#delivery; + if (delivery.mode === "pending") this.#pending.push({ type: "close", event }); + else if (delivery.mode === "classic") { + this.#deliverClassic("close", event, delivery.criticalSection); + } else delivery.registry.receive(this, "close", event); + } + + #deliverClassic( + type: SocketEvent, + event: Event, + criticalSection: CriticalSection | undefined, + ): void { this.#ctx.addWaitUntil( this.#ctx.run(() => { - // One rebuilt event for both forms: handing the handler the raw one would give it a - // different `target` from the listener beside it, for the same frame. const delivered = cloneEventFor(type, event); this.dispatchEvent(delivered); - // Consumers use both forms — a client library sets handlers, a server library listens — - // so both are called, exactly as `WebSocketFacade` does for the same reason. const handler = this[`on${type}`] as ((event: Event) => void) | null; handler?.(delivered); - }, { input: this.#criticalSection }), + }, { input: criticalSection }), ); } - /** - * ← `WebSocket::send` (`web-socket.c++:~640`), which inserts a - * `GatedMessage{IoContext::current().waitForOutputLocksIfNecessary(), …}`. - * - * Synchronous, as upstream's is: the wait is the pump's, not the caller's. The - * output gate is what "blocks all outgoing messages from an actor that would - * allow the rest of the world to observe the actor's state" (§1.1), and a - * socket frame is exactly such a message. - * - * `waitForOutputLocksIfNecessary()` collapses to `waitForOutputLocks()` here - * for the reason the whole file collapses `kj::Maybe`: its - * body is `actor.map(…)` (`io-context.c++:383-386`) and every context in this - * runtime is an actor context. - */ - send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void { - this.#enqueue(() => { - this.#socket.send(data); - }); - } - - /** ← `WebSocket::close`, which enqueues a `Close` through the same gate. */ - close(code?: number, reason?: string): void { - this.#enqueue(() => { - this.#socket.close(code, reason); - }); - } - #enqueue(write: () => void): void { - // Captured HERE, at the call, so message N waits for the writes outstanding when it was - // enqueued and not for whatever is outstanding when the pump reaches it. const outputLock = this.#ctx.waitForOutputLocks(); - // A failed write mutes the socket, and that is upstream's semantics rather than an accident - // to repair. `LegacyWebSocketAdapter::pump`'s `KJ_DEFER` (`web-socket.c++:1187-1207`) clears - // `outgoingMessages` unconditionally and sets `native.outgoingAborted` on any unwind that is - // not a clean completion, and `send()` (`:816`) and `close()` (`:916`) then return silently — - // so a queued frame AND a queued close are both dropped, forever, after one throwing send. - // - // Here that falls out of `.then(onFulfilled)` skipping its callback on a rejected chain. - // Do not "fix" it into `.then(a, b)`, `.catch()`, or an awaited predecessor: each of those - // resumes delivery where upstream stays silent, and `web-socket.test.ts` pins the difference. this.#pump = this.#pump.then(async () => { await outputLock; write(); }); - // The chain is the actor's work, so a broken output gate reports where every other background - // failure reports rather than as an unhandled rejection. this.#ctx.addWaitUntil(this.#pump); } } -/** - * ← `accept()` / `state.acceptWebSocket()`, as the one verb. - * - * Named for what upstream names it, because the critical-section capture is a - * property of accepting rather than of constructing: "a socket accepted inside a - * `blockConcurrencyWhile` delivers its messages inside that critical section" - * (§1.8). - */ +for (const [name, value] of Object.entries(WEB_SOCKET_READY_STATES)) { + Object.defineProperty(AcceptedWebSocket.prototype, name, { value, enumerable: true }); +} + +type HandlerDispatch = { + message(socket: RawWebSocket, message: string | ArrayBuffer): unknown; + close(socket: RawWebSocket, code: number, reason: string, wasClean: boolean): unknown; + error(socket: RawWebSocket, error: unknown): unknown; +}; + +type RegistryEntry = { + readonly socket: RawWebSocket; + readonly tags: string[]; + autoResponseTimestamp?: number; +}; + +export class HibernatableWebSocketRegistry { + readonly #ctx: IoContext; + readonly #dispatch: HandlerDispatch; + readonly #host: HibernationHost | undefined; + readonly #entries: RegistryEntry[] = []; + #autoResponse: WebSocketRequestResponsePair | null = null; + #eventTimeout: number | null = null; + #pairConstructor: WebSocketPairConstructor | undefined; + + constructor( + ctx: IoContext, + dispatch: HandlerDispatch, + host?: HibernationHost, + rehydrated: readonly RehydratedWebSocket[] = [], + ) { + this.#ctx = ctx; + this.#dispatch = dispatch; + this.#host = host; + for (const value of rehydrated) this.#rehydrate(value); + } + + get WebSocketPair(): WebSocketPairConstructor { + const registry = this; + this.#pairConstructor ??= new Proxy( + class WebSocketPair { + declare readonly 0: WebSocket; + declare readonly 1: WebSocket; + }, + { construct: () => registry.#createPair() }, + ); + return this.#pairConstructor; + } + + acceptWebSocket(socket: RawWebSocket, tags?: string[]): void { + if (!isRawWebSocket(socket)) { + throw new TypeError( + "Failed to execute 'acceptWebSocket' on 'DurableObjectState': parameter 1 is not of type 'WebSocket'.", + ); + } + const state = socketMetadata(socket); + if (state.accepted !== undefined) throw new Error(HIBERNATION_ALREADY_ACCEPTED_MESSAGE); + if (this.#entries.length >= MAX_HIBERNATABLE_SOCKETS) { + throw new Error( + `only ${MAX_HIBERNATABLE_SOCKETS} websockets can be accepted on a single Durable Object instance`, + ); + } + const normalizedTags = normalizeTags(tags); + if (socket instanceof AcceptedWebSocket) socket.acceptHibernation(this); + else this.#listenRaw(socket); + state.accepted = { mode: "hibernatable", registry: this }; + this.#entries.push({ socket, tags: normalizedTags }); + this.#host?.accepted(socket, normalizedTags); + } + + getWebSockets(tag?: string): WebSocket[] { + if (arguments.length > 0) { + if (typeof tag !== "string") return []; + return this.#entries + .filter((entry) => entry.tags.includes(tag)) + .map((entry) => entry.socket as WebSocket); + } + return this.#entries.map((entry) => entry.socket as WebSocket).reverse(); + } + + getTags(socket: RawWebSocket): string[] { + const state = isRawWebSocket(socket) ? metadata.get(socket) : undefined; + if (state?.accepted === undefined) { + throw new Error( + "you must call 'acceptWebSocket()' before attempting to access the tags of a WebSocket.", + ); + } + if (state.accepted.mode !== "hibernatable") { + throw new Error("only hibernatable websockets can have tags."); + } + const entry = this.#entries.find((candidate) => candidate.socket === socket); + if (entry === undefined) { + throw new Error( + "you must call 'acceptWebSocket()' before attempting to access the tags of a WebSocket.", + ); + } + return [...entry.tags]; + } + + setWebSocketAutoResponse(pair?: WebSocketRequestResponsePair): void { + if (pair === undefined) { + this.#autoResponse = null; + this.#host?.autoResponse(null); + return; + } + if (!(pair instanceof WebSocketRequestResponsePairImpl)) { + throw new TypeError( + "Failed to execute 'setWebSocketAutoResponse' on 'DurableObjectState': parameter 1 is not of type 'WebSocketRequestResponsePair'.", + ); + } + validateAutoResponseSize("Request", pair.request); + validateAutoResponseSize("Response", pair.response); + this.#autoResponse = pair; + this.#host?.autoResponse({ request: pair.request, response: pair.response }); + } + + getWebSocketAutoResponse(): WebSocketRequestResponsePair | null { + const pair = this.#autoResponse; + return pair === null + ? null + : new RuntimeWebSocketRequestResponsePair(pair.request, pair.response); + } + + getWebSocketAutoResponseTimestamp(socket: RawWebSocket): Date | null { + if (!isRawWebSocket(socket)) { + throw new TypeError( + "Failed to execute 'getWebSocketAutoResponseTimestamp' on 'DurableObjectState': parameter 1 is not of type 'WebSocket'.", + ); + } + const timestamp = this.#entries.find((entry) => entry.socket === socket)?.autoResponseTimestamp; + return timestamp === undefined ? null : new Date(timestamp); + } + + setHibernatableWebSocketEventTimeout(value?: number): void { + if (value === undefined || Number(value) === 0) { + this.#eventTimeout = null; + return; + } + const number = Number(value); + if (Number.isNaN(number)) { + throw new TypeError("The value cannot be converted because it is not an integer."); + } + if (number < 0) { + throw new TypeError( + "The value cannot be converted because it is negative and this API expects a positive number.", + ); + } + if (number > 0xffff_ffff) { + throw new TypeError("Value out of range. Must be less than or equal to 4294967295."); + } + const timeout = Math.trunc(number); + if (timeout > MAX_EVENT_TIMEOUT) { + throw new Error(`Event timeout should not exceed ${MAX_EVENT_TIMEOUT} ms.`); + } + this.#eventTimeout = timeout; + } + + getHibernatableWebSocketEventTimeout(): number | null { + return this.#eventTimeout; + } + + attachmentChanged(socket: RawWebSocket, bytes: Uint8Array): void { + if (this.#entries.some((entry) => entry.socket === socket)) { + this.#host?.attachment(socket, bytes); + } + } + + receive(socket: RawWebSocket, type: SocketEvent, event: Event): void { + const entry = this.#entries.find((candidate) => candidate.socket === socket); + if (entry === undefined) return; + if (type === "message") { + const data = (event as MessageEvent).data as unknown; + if (typeof data === "string" && data === this.#autoResponse?.request) { + entry.autoResponseTimestamp = this.#ctx.now(); + socket.send(this.#autoResponse.response); + return; + } + const message = cloneMessageData(data); + if (message instanceof Blob) { + this.#ctx.addWaitUntil( + message.arrayBuffer().then((buffer) => { + this.#schedule(() => this.#dispatch.message(socket, buffer)); + }), + ); + return; + } + this.#schedule(() => this.#dispatch.message(socket, message)); + return; + } + if (type === "close") { + this.#remove(entry); + const close = event as CloseEvent; + this.#schedule(() => + this.#dispatch.close(socket, close.code, close.reason, close.wasClean), + ); + return; + } + if (type === "error") this.#schedule(() => this.#dispatch.error(socket, event)); + } + + #schedule(handler: () => unknown): void { + this.#ctx.addWaitUntil(this.#ctx.run(handler).then(() => {})); + } + + #remove(entry: RegistryEntry): void { + const index = this.#entries.indexOf(entry); + if (index === -1) return; + this.#entries.splice(index, 1); + this.#host?.closed(entry.socket); + } + + #listenRaw(socket: RawWebSocket): void { + const state = socketMetadata(socket); + if (state.rawListenersInstalled === true) return; + state.rawListenersInstalled = true; + for (const type of ["message", "close", "error"] as const) { + socket.addEventListener(type, (event) => { + const accepted = socketMetadata(socket).accepted; + if (accepted?.mode === "hibernatable") { + accepted.registry.receive(socket, type, event); + } + }); + } + } + + #rehydrate(value: RehydratedWebSocket): void { + const socket = value.socket; + if (!isRawWebSocket(socket)) { + throw new TypeError("ActorContainerOptions.webSockets contains a non-WebSocket value."); + } + const tags = normalizeTags(value.tags); + const state = socketMetadata(socket); + state.accepted = { mode: "hibernatable", registry: this }; + if (value.attachment !== undefined) { + state.attachment = value.attachment.slice(); + } + if (socket instanceof AcceptedWebSocket) socket.rehydrateHibernation(this, this.#ctx); + else this.#listenRaw(socket); + const entry: RegistryEntry = { socket, tags }; + if (value.autoResponseTimestamp !== undefined) { + entry.autoResponseTimestamp = value.autoResponseTimestamp; + } + this.#entries.push(entry); + } + + #createPair(): RuntimeWebSocketPair { + const pairState: PairState = { used: false, hibernationAccepted: false }; + const left = new MemoryWebSocketEndpoint(); + const right = new MemoryWebSocketEndpoint(); + left.peer = right; + right.peer = left; + return { + 0: new AcceptedWebSocket(this.#ctx, left, pairState), + 1: new AcceptedWebSocket(this.#ctx, right, pairState), + }; + } +} + export function acceptWebSocket(ctx: IoContext, socket: RawWebSocket): AcceptedWebSocket { - if (accepted.has(socket)) throw new Error(ALREADY_ACCEPTED_MESSAGE); - accepted.add(socket); - return new AcceptedWebSocket(ctx, socket); + const state = socketMetadata(socket); + if (state.accepted !== undefined) throw new Error(ALREADY_ACCEPTED_MESSAGE); + if (socket instanceof AcceptedWebSocket) { + socket.accept(); + return socket; + } + state.accepted = { mode: "classic" }; + const accepted = new AcceptedWebSocket(ctx, socket); + return accepted; +} + +export function markWebSocketUsed(socket: RawWebSocket): void { + if (socket instanceof AcceptedWebSocket) socket.markPairUsed(); +} + +export function installWebSocketGlobals( + target: object, + pairConstructor: WebSocketPairConstructor, +): void { + const constructor = globalThis.WebSocket; + for (const [name, value] of Object.entries(WEB_SOCKET_READY_STATES)) { + defineValue(constructor, name, value); + defineValue(constructor.prototype, name, value); + } + defineValue(constructor.prototype, "serializeAttachment", serializeAttachmentMethod); + defineValue(constructor.prototype, "deserializeAttachment", deserializeAttachmentMethod); + defineValue(target, "WebSocket", constructor); + defineValue(target, "WebSocketPair", pairConstructor); + defineValue(target, "WebSocketRequestResponsePair", RuntimeWebSocketRequestResponsePair); +} + +function defineValue(target: object, name: string, value: unknown): void { + const current = Object.getOwnPropertyDescriptor(target, name); + if (current?.configurable === false) return; + Object.defineProperty(target, name, { configurable: true, writable: true, value }); +} + +function normalizeTags(tags: unknown): string[] { + if (tags === undefined) return []; + if (!Array.isArray(tags)) { + throw new TypeError( + "Failed to execute 'acceptWebSocket' on 'DurableObjectState': parameter 2 is not of type 'Array'.", + ); + } + if (tags.length > MAX_TAGS) { + throw new Error(`a Hibernatable WebSocket cannot have more than ${MAX_TAGS} tags`); + } + const normalized = [...new Set(tags.map(String))]; + for (const tag of normalized) { + if (tag.length > MAX_TAG_LENGTH) { + throw new Error( + `"${tag}" is longer than the max tag length (${MAX_TAG_LENGTH} characters).`, + ); + } + } + return normalized; +} + +function validateAutoResponseSize(side: "Request" | "Response", value: string): void { + const bytes = textEncoder.encode(value).byteLength; + if (bytes > MAX_AUTO_RESPONSE_BYTES) { + throw new RangeError( + `${side} cannot be larger than ${MAX_AUTO_RESPONSE_BYTES} bytes. A ${side.toLowerCase()} of size ${bytes} was provided.`, + ); + } +} + +function validateClose(code: number | undefined, reason: string): void { + if (code !== undefined && code !== 1000 && (code < 3000 || code > 4999)) { + throw new DOMException(`Invalid WebSocket close code: ${code}.`, "InvalidAccessError"); + } + if (textEncoder.encode(reason).byteLength > MAX_CLOSE_REASON_BYTES) { + throw new DOMException( + `WebSocket close reason must not be longer than ${MAX_CLOSE_REASON_BYTES} bytes when UTF-8 encoded.`, + "SyntaxError", + ); + } } -/** - * An `Event` may be dispatched by exactly one target at a time, so the raw - * socket's event object cannot be re-dispatched: `dispatchEvent` on an event - * that is already dispatched throws `InvalidStateError`, and one that has - * finished carries the raw socket as its `target`. Rebuilding it is what makes - * `event.target` the accepted socket, which is what a listener expects. - */ function cloneEventFor(type: SocketEvent, event: Event): Event { if (type === "message") { const source = event as MessageEvent; diff --git a/src/index.ts b/src/index.ts index 6b820c7..f43390b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -98,6 +98,7 @@ export type { ActorContainerOptions, ActorEntry, ActorPorts, + HibernationHost, FacetHandle, FacetHost, FacetId, @@ -137,7 +138,6 @@ export { asLoopbackDurableObjectClass, LoopbackDurableObjectClass } from "./api/ export { FACET_NAME_MAX_LENGTH, FACET_TREE_MAX_DEPTH, - HIBERNATION_UNIMPLEMENTED_MESSAGE, } from "./api/actor-state"; export { PITR_UNIMPLEMENTED_MESSAGE, REPLICATION_UNIMPLEMENTED_MESSAGE } from "./io/actor-cache"; /** @@ -203,8 +203,13 @@ export { installActorScope, NO_GLOBAL_OUTBOUND_MESSAGE, } from "./api/global-scope"; -export type { AcceptedWebSocket, RawWebSocket } from "./api/web-socket"; -export { ALREADY_ACCEPTED_MESSAGE } from "./api/web-socket"; +export type { AcceptedWebSocket, RawWebSocket, RehydratedWebSocket } from "./api/web-socket"; +export { + ALREADY_ACCEPTED_MESSAGE, + WebSocketRequestResponsePair, + installWebSocketGlobals, + markWebSocketUsed, +} from "./api/web-socket"; export { BYOB_READER_UNGATABLE_MESSAGE, gateRequestBody } from "./api/http"; /** * The transport, for the same reason the loader binding and the scheduler are diff --git a/src/io/io-context.test.ts b/src/io/io-context.test.ts index 3c6ff83..db7b733 100644 --- a/src/io/io-context.test.ts +++ b/src/io/io-context.test.ts @@ -922,6 +922,24 @@ it("clearTimeout before the deadline stops the callback and frees the entry", as expect(ctx.waitUntilStatus()).toBeUndefined(); }); +it("clearing a timer drains waitUntil when the timer port cancels by dropping its promise", async () => { + const timer: Timer = { + now: () => 0, + afterDelay: () => new Promise(() => {}), + }; + const ctx = new IoContext(new TestActor(), timer); + let id = 0; + + await ctx.run(() => { + id = ctx.setTimeoutImpl(false, () => {}, 50); + }); + expect(ctx.waitUntilTaskCount()).toBe(1); + + ctx.clearTimeoutImpl(id); + expect(await poll(ctx.drainWaitUntil())).toBe(true); + expect(ctx.waitUntilTaskCount()).toBe(0); +}); + it("clearTimeout for an unknown id is a no-op", async () => { // "We can't find this timeout, thus we act as if it was already canceled." const { ctx } = newContext(); diff --git a/src/io/io-context.ts b/src/io/io-context.ts index c3ad9db..d06f317 100644 --- a/src/io/io-context.ts +++ b/src/io/io-context.ts @@ -600,21 +600,29 @@ class TimeoutManager { const wake = new AbortController(); state.armed = wake; - const fired = this.#timer.afterDelay(state.params.msDelay, wake.signal).then( - async () => { - if (state.isCanceled) return; - state.armed = undefined; - await this.#fire(ctx, id, state, criticalSection); - }, - (exception: unknown) => { - // kj cancels by dropping the promise, which cannot report anything; Section 1 records - // that the substitution turns every cancel-by-drop into an `AbortSignal` whose waiter - // rejects with `CanceledError`. A wake THIS manager aborted is that cancellation and not - // a failure. Anything else is the timer port's and is reported, so a substrate that - // fails to keep time cannot look like a timer nobody armed. - if (!state.isCanceled) throw exception; - }, - ); + // A Timer may model kj's cancel-by-drop by leaving its delay promise unsettled. + // The manager still has to finish its own waitUntil bookkeeping when it drops + // that wait, or a cleared timer would keep the actor permanently non-idle. + const canceled = new Promise((resolve) => { + wake.signal.addEventListener("abort", () => resolve(), { once: true }); + }); + + const fired = Promise.race([ + this.#timer.afterDelay(state.params.msDelay, wake.signal).then( + async () => { + if (state.isCanceled) return; + state.armed = undefined; + await this.#fire(ctx, id, state, criticalSection); + }, + (exception: unknown) => { + // A timer port may reject its waiter when this manager aborts it. That is cancellation, + // not a clock failure. Anything else is reported, so a substrate that fails to keep + // time cannot look like a timer nobody armed. + if (!state.isCanceled) throw exception; + }, + ), + canceled, + ]); // ← `context.addWaitUntil(kj::mv(paf.promise))` (`io-context.c++:845-851`): "Add a wait-until // task which resolves when this timer completes. This ensures that `IncomingRequest::drain()` @@ -688,6 +696,10 @@ class TaskSet { this.#tasks.add(task); } + size(): number { + return this.#tasks.size; + } + /** ← `kj::TaskSet::onEmpty()`. Re-checks, since a task can add another. */ async onEmpty(): Promise { while (this.#tasks.size > 0) { @@ -1010,6 +1022,10 @@ export class IoContext { return this.#waitUntilStatus?.exception; } + waitUntilTaskCount(): number { + return this.#waitUntilTasks.size(); + } + /** * ← `IncomingRequest::drain()`, actor branch. "For actors, all promises are canceled on * actor shutdown, not on a fixed timeout, because work doesn't necessarily happen on a diff --git a/src/server/actor-container.ts b/src/server/actor-container.ts index aa1370b..0eaceaf 100644 --- a/src/server/actor-container.ts +++ b/src/server/actor-container.ts @@ -49,8 +49,13 @@ import { actorScopeBindings, isAlarmFailureUserError, } from "../api/global-scope"; -import type { AcceptedWebSocket, RawWebSocket } from "../api/web-socket"; -import { acceptWebSocket } from "../api/web-socket"; +import type { + AcceptedWebSocket, + HibernationHost, + RawWebSocket, + RehydratedWebSocket, +} from "../api/web-socket"; +import { acceptWebSocket, HibernatableWebSocketRegistry } from "../api/web-socket"; import type { IsolateChannelFactory, WorkerLoaderOptions } from "../api/worker-loader"; import { WorkerLoader } from "../api/worker-loader"; import type { AlarmOutlet } from "../io/actor-sqlite"; @@ -58,6 +63,7 @@ import { ActorSqlite, DEFAULT_ALARM_OUTLET } from "../io/actor-sqlite"; import type { AlarmResult } from "./alarm-scheduler"; import type { Actor, Timer } from "../io/io-context"; import { IoContext, captureGateStack, tryCurrentIoContext } from "../io/io-context"; +import type { InputGateHooks, OutputGateHooks } from "../io/io-gate"; import { InputGate, OutputGate } from "../io/io-gate"; import type { FacetManager, FacetStartInfo } from "../io/worker"; import { asFacetStub } from "../io/worker"; @@ -231,7 +237,7 @@ export const noFacets: FacetHost = { }; /** - * The four ports. Each one is a seam workerd itself takes as a constructor + * The five ports. Each one is a seam workerd itself takes as a constructor * input; a port that would exist only because our code is currently shaped * badly is an invented seam and was rejected. Rejected, for the record: * a transport port (one implementation per substrate, forever), a logger port @@ -273,8 +279,11 @@ export type ActorPorts = { * prevent. */ fetch?: FetchPort; + hibernation?: HibernationHost; }; +export type { HibernationHost } from "../api/web-socket"; + /** * The whole-tree facet state, which belongs to the root container and is shared * by every container in one actor tree. @@ -351,6 +360,8 @@ export type ActorContainerOptions = { exports: Record; env: unknown; ports: ActorPorts; + webSockets?: readonly RehydratedWebSocket[]; + gateHooks?: { input?: InputGateHooks; output?: OutputGateHooks }; /** Present when this container hosts a facet rather than a root. */ facet?: { /** Root is 0, a direct child of the root is 1. `getDepth()` answers with it. */ @@ -546,6 +557,13 @@ export interface ActorContainer { /** For the host's idle check — today's `drainWaitUntil`. */ drainWaitUntil(): Promise; + quiescence(): { + armedTimers: number; + pendingWaitUntil: number; + inputLockHeld: boolean; + outputGateBroken: boolean; + }; + /** * ← `WorkerdApi::compileGlobals`'s `Global::WorkerLoader` arm * (`server/workerd-api.c++:748-752`), which is the step that turns a configured @@ -822,8 +840,8 @@ type ClassInstance = * which is the whole mechanism behind §1.10's parent↔child re-entrancy. */ class ActorImpl implements Actor { - readonly #inputGate = new InputGate(); - readonly #outputGate = new OutputGate(); + readonly #inputGate: InputGate; + readonly #outputGate: OutputGate; readonly #isFacet: boolean; /** Assigned after construction; `storage` is a WXT auto-import in extension bundles. */ @@ -831,8 +849,13 @@ class ActorImpl implements Actor { classInstance: ClassInstance = { kind: "before-ctor" }; - constructor(isFacet: boolean) { + constructor( + isFacet: boolean, + hooks: ActorContainerOptions["gateHooks"] = {}, + ) { this.#isFacet = isFacet; + this.#inputGate = new InputGate(hooks.input); + this.#outputGate = new OutputGate(hooks.output); } getInputGate(): InputGate { @@ -1263,6 +1286,7 @@ class ActorContainerImpl implements ActorContainer { readonly #facets: FacetManagerImpl; readonly #tree: ActorTree | undefined; readonly #env: unknown; + readonly #webSockets: HibernatableWebSocketRegistry; readonly state: DurableObjectState; readonly facetTree: FacetTree; readonly globals: ActorGlobalScope; @@ -1277,7 +1301,7 @@ class ActorContainerImpl implements ActorContainer { facetTree: FacetTree, ) { const facet = options.facet; - this.#actor = new ActorImpl(facet !== undefined); + this.#actor = new ActorImpl(facet !== undefined, options.gateHooks); this.#ctx = new IoContext(this.#actor, options.ports.timer); this.#env = options.env; this.#tree = tree; @@ -1301,9 +1325,21 @@ class ActorContainerImpl implements ActorContainer { this.#actor.actorStorage = this.#cache; this.#durableStorage = new DurableObjectStorage(this.#ctx, this.#cache); + this.#webSockets = new HibernatableWebSocketRegistry( + this.#ctx, + { + message: (socket, message) => this.#runWebSocketHandler("webSocketMessage", socket, message), + close: (socket, code, reason, wasClean) => + this.#runWebSocketHandler("webSocketClose", socket, code, reason, wasClean), + error: (socket, error) => this.#runWebSocketHandler("webSocketError", socket, error), + }, + options.ports.hibernation, + options.webSockets, + ); this.globals = new ActorGlobalScope(this.#ctx, { fetch: options.ports.fetch, currentExternalEntry: () => this.#currentExternalEntry, + webSockets: this.#webSockets, }); this.facetTree = facetTree; this.#facets = new FacetManagerImpl( @@ -1332,9 +1368,10 @@ class ActorContainerImpl implements ActorContainer { storage: this.#durableStorage, facets: this.#facets, // The same object `container.globals` is, so a class that reaches through - // `ctx` and a dynamically-loaded source that destructured the seven names + // `ctx` and a dynamically-loaded source that destructured the actor globals // are gated by one scope rather than two that could drift. globals: actorScopeBindings(() => this.globals), + webSockets: this.#webSockets, }); } @@ -1525,6 +1562,15 @@ class ActorContainerImpl implements ActorContainer { return this.#ctx.drainWaitUntil(); } + quiescence() { + return { + armedTimers: this.#ctx.getTimeoutCount(), + pendingWaitUntil: this.#ctx.waitUntilTaskCount(), + inputLockHeld: this.#ctx.hasCurrent(), + outputGateBroken: this.#ctx.isOutputGateBroken(), + }; + } + /** ← `WorkerdApi::compileGlobals`'s `Global::WorkerLoader` arm. */ workerLoader(channel: IsolateChannelFactory, options: WorkerLoaderOptions): WorkerLoader { return new WorkerLoader(this.#ctx, channel, options); @@ -1642,6 +1688,18 @@ class ActorContainerImpl implements ActorContainer { new AlarmInvocationInfo(scheduledTime, retryCount), ); } + + #runWebSocketHandler( + name: "webSocketMessage" | "webSocketClose" | "webSocketError", + socket: RawWebSocket, + ...args: unknown[] + ): unknown { + const instance = this.#actor.classInstance; + if (instance.kind !== "running") return undefined; + const handler: unknown = Reflect.get(instance.instance, name); + if (typeof handler !== "function") return undefined; + return Reflect.apply(handler, instance.instance, [socket, ...args]); + } } // =======================================================================================