From 0b009acddb15833719dc6101128e8c93f26c3c0d Mon Sep 17 00:00:00 2001 From: Alex Nahas Date: Fri, 28 Aug 2026 11:23:27 -0700 Subject: [PATCH 1/7] Specify hibernatable WebSockets against workerd Capture the pinned runtime's acceptance, attachment, dispatch, close, auto-response, timeout, tag, and eviction behavior before changing do-runtime. The shared host now exposes connection and eviction affordances so the same oracle spec can drive the Node and browser implementations. --- conformance/fixtures/probe.ts | 590 ++++++++++++++++++++++++++ conformance/host.ts | 16 +- conformance/suite/hibernation.spec.ts | 442 +++++++++++++++++++ conformance/workerd/host.ts | 78 +++- 4 files changed, 1116 insertions(+), 10 deletions(-) create mode 100644 conformance/suite/hibernation.spec.ts diff --git a/conformance/fixtures/probe.ts b/conformance/fixtures/probe.ts index eb43da7..a21c9b0 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], + constructor: [ + 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/suite/hibernation.spec.ts b/conformance/suite/hibernation.spec.ts new file mode 100644 index 0000000..ffee52f --- /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], + constructor: [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/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" }); + }, }; From 57b8d0af17f17e0e4322f31660c8438a1847212d Mon Sep 17 00:00:00 2001 From: Alex Nahas Date: Fri, 28 Aug 2026 11:28:53 -0700 Subject: [PATCH 2/7] Pin the hibernation embedder contract red Exercise host mirroring, constructor-visible rehydration, socket lifecycle, quiescence, and gate hook injection through the public container surface. These tests intentionally fail on the current throwing stubs before implementation begins. --- src/api/hibernatable-web-socket.test.ts | 294 ++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 src/api/hibernatable-web-socket.test.ts diff --git a/src/api/hibernatable-web-socket.test.ts b/src/api/hibernatable-web-socket.test.ts new file mode 100644 index 0000000..643cd15 --- /dev/null +++ b/src/api/hibernatable-web-socket.test.ts @@ -0,0 +1,294 @@ +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) => { + const handle = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => clearTimeout(handle)); + }), +}; + +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 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); + } +} + +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"); + entry.attachment = bytes === null ? undefined : 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); + + 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 WebSocketRequestResponsePair("ping", "pong")); + }); + + 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); + }); + + 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: 1, + inputLockHeld: true, + outputGateBroken: false, + }); + expect(container.quiescence()).toEqual({ + armedTimers: 1, + pendingWaitUntil: 1, + inputLockHeld: false, + outputGateBroken: false, + }); + + pending.resolve(); + await container.run(() => container.globals.clearInterval(interval)); + await container.drainWaitUntil(); + 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"); +}); From 797b40342fe07e291971958ee027e75b5cd8975a Mon Sep 17 00:00:00 2001 From: Alex Nahas Date: Fri, 28 Aug 2026 11:58:57 -0700 Subject: [PATCH 3/7] Implement hibernatable WebSocket lifecycle Replace the fail-closed state stubs with runtime-owned WebSocketPair endpoints, attachment and tag storage, class-handler dispatch, auto responses, and close-state handling. Add the mirror-out HibernationHost and pre-constructor rehydration option so an embedder can rebuild a container without reconnecting a live client. Node and browser lane hosts now exercise that same lifecycle, including a real eviction-and-wake cycle. --- conformance/browser/actor.worker.ts | 144 +++- conformance/browser/host.ts | 56 +- conformance/browser/protocol.ts | 9 + conformance/fixtures/probe.ts | 2 +- conformance/node/host.ts | 156 ++++- conformance/suite/hibernation.spec.ts | 2 +- conformance/websocket-upgrade.ts | 45 ++ src/api/actor-state.test.ts | 22 - src/api/actor-state.ts | 60 +- src/api/global-scope.test.ts | 5 +- src/api/global-scope.ts | 25 + src/api/hibernatable-web-socket.test.ts | 20 +- src/api/web-socket.ts | 841 +++++++++++++++++++----- src/index.ts | 12 +- src/io/io-context.ts | 8 + src/server/actor-container.ts | 71 +- 16 files changed, 1249 insertions(+), 229 deletions(-) create mode 100644 conformance/websocket-upgrade.ts diff --git a/conformance/browser/actor.worker.ts b/conformance/browser/actor.worker.ts index b69745c..9d5a61a 100644 --- a/conformance/browser/actor.worker.ts +++ b/conformance/browser/actor.worker.ts @@ -64,6 +64,9 @@ import { type FacetTree, type IsolateChannelFactory, type LoadIsolateRequest, + type HibernationHost, + type RawWebSocket, + type RehydratedWebSocket, type WorkerSource, type WorkerStubChannel, } from "../../src/index"; @@ -79,6 +82,12 @@ import { import { Probe } from "../fixtures/probe"; import type { ActorBoot, ActorRpc, SupervisorRpc } from "./protocol"; import { installPool, timer, UNIQUE_KEY } from "./substrate"; +import { + installWebSocketUpgradeGlobals, + upgradeWebSocket, + webSocketUpgradeRequest, + type UpgradeWebSocket, +} from "../websocket-upgrade"; type Session = ReturnType>; @@ -107,6 +116,92 @@ let placing: Promise | undefined; /** The page. */ let peer: Session | undefined; +class BrowserHibernationHost 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("Browser lane: attachment preceded socket acceptance."); + } + this.#entries.set(socket, { + socket, + tags: entry.tags, + ...(bytes === null ? {} : { attachment: bytes.slice() }), + ...(entry.autoResponseTimestamp === undefined + ? {} + : { autoResponseTimestamp: entry.autoResponseTimestamp }), + }); + } + + 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()]; + } +} + +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 BrowserHibernationHost(); +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 +268,7 @@ function rootScope(op: string): ActorGlobalScope { */ function installRootScope(): void { installActorScope(globalThis, () => rootScope("a root global")); + installWebSocketUpgradeGlobals(); } // ======================================================================================= @@ -487,7 +583,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 +592,9 @@ const FACET_SCOPE_NAMES = [ "clearInterval", "fetch", "crypto", + "WebSocket", + "WebSocketPair", + "WebSocketRequestResponsePair", ] as const; async function facetModule(className: string, gate: FacetGate): Promise { @@ -671,11 +770,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 +855,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..7f999e8 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) + ? (data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer) + : 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 a21c9b0..44f9da0 100644 --- a/conformance/fixtures/probe.ts +++ b/conformance/fixtures/probe.ts @@ -1379,7 +1379,7 @@ export class Probe extends DurableObject> { const proto = WebSocket.prototype as WebSocket & Record; return { fresh: [client.readyState, server.readyState], - constructor: [ + constructorValues: [ ctor.READY_STATE_CONNECTING, ctor.READY_STATE_OPEN, ctor.READY_STATE_CLOSING, diff --git a/conformance/node/host.ts b/conformance/node/host.ts index fabac55..6dc5206 100644 --- a/conformance/node/host.ts +++ b/conformance/node/host.ts @@ -32,8 +32,12 @@ 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 { RawWebSocket, RehydratedWebSocket, Timer } from "../../src/index"; +import { + AlarmScheduler, + createActorContainer, + installWebSocketGlobals, +} from "../../src/index"; import type { ActorContainer, ActorEntry, @@ -42,6 +46,7 @@ import type { FacetId, FacetStartRequest, FacetTree, + HibernationHost, } from "../../src/index"; import type { ActorClassChannel, @@ -50,12 +55,25 @@ import type { } from "../../src/io/io-channels"; import type { WorkerSource } from "../../src/io/worker-source"; import type { IsolateChannelFactory, LoadIsolateRequest } from "../../src/api/worker-loader"; +import type { RuntimeWebSocketPairConstructor } from "../../src/api/web-socket"; 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 { + installWebSocketUpgradeGlobals, + upgradeWebSocket, + webSocketUpgradeRequest, + type UpgradeWebSocket, +} from "../websocket-upgrade"; // ======================================================================================= // The platform globals workerd has natively @@ -163,6 +181,21 @@ globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { return container.globals.fetch(input, init); }) as typeof globalThis.fetch; +const actorWebSocketPair = new Proxy(class WebSocketPair {}, { + construct(): object { + const pair = current.getStore()?.globals.WebSocketPair; + if (pair === undefined) { + throw new Error("Node lane: WebSocketPair was constructed outside an actor event."); + } + return Reflect.construct(pair, []); + }, +}); +installWebSocketGlobals( + globalThis, + actorWebSocketPair as unknown as RuntimeWebSocketPairConstructor, +); +installWebSocketUpgradeGlobals(); + /** * `crypto`, on the same ambient as the timers above. * @@ -476,6 +509,96 @@ type Placement = { readonly stub: object; }; +class NodeHibernationHost 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("Node lane: attachment preceded socket acceptance."); + this.#entries.set(socket, { + socket, + tags: entry.tags, + ...(bytes === null ? {} : { attachment: bytes.slice() }), + ...(entry.autoResponseTimestamp === undefined + ? {} + : { autoResponseTimestamp: entry.autoResponseTimestamp }), + }); + } + + 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()]; + } +} + +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 +760,15 @@ function alarmScheduler(): Promise { } const live = new Map(); +const socketHosts = new Map(); + +function socketHost(name: string): NodeHibernationHost { + const existing = socketHosts.get(name); + if (existing !== undefined) return existing; + const host = new NodeHibernationHost(); + socketHosts.set(name, host); + return host; +} async function placed(name: string): Promise { return live.get(name) ?? (await place(name)); @@ -651,6 +783,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 +807,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 +908,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 index ffee52f..151d52a 100644 --- a/conformance/suite/hibernation.spec.ts +++ b/conformance/suite/hibernation.spec.ts @@ -288,7 +288,7 @@ describe("handler dispatch and close state", () => { const actor = await host.spawn("ws-ready-state"); expect(await actor.call("readyStateConstants")).toEqual({ fresh: [1, 1], - constructor: [0, 1, 2, 3, 0, 1, 2, 3], + constructorValues: [0, 1, 2, 3, 0, 1, 2, 3], prototype: [0, 1, 2, 3, 0, 1, 2, 3], }); }); 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/src/api/actor-state.test.ts b/src/api/actor-state.test.ts index 49853d7..f51a1b5 100644 --- a/src/api/actor-state.test.ts +++ b/src/api/actor-state.test.ts @@ -60,7 +60,6 @@ import { FACET_CLASS_UNSUPPORTED_MESSAGE, FACET_NAME_MAX_LENGTH, FACET_TREE_MAX_DEPTH, - HIBERNATION_UNIMPLEMENTED_MESSAGE, type StorageCache, } from "./actor-state"; @@ -995,27 +994,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..62c3986 100644 --- a/src/api/actor-state.ts +++ b/src/api/actor-state.ts @@ -71,6 +71,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 +86,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 +164,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 +181,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 +998,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 +1137,43 @@ 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[]): void { + this.#webSockets().acceptWebSocket(ws, tags); + } - acceptWebSocket(_ws: WebSocket, _tags?: string[]): never { - throw new Error(HIBERNATION_UNIMPLEMENTED_MESSAGE); + getWebSockets(tag?: string): WebSocket[] { + return tag === undefined + ? this.#webSockets().getWebSockets() + : this.#webSockets().getWebSockets(tag); } - getWebSockets(_tag?: string): never { - throw new Error(HIBERNATION_UNIMPLEMENTED_MESSAGE); + setWebSocketAutoResponse(maybeReqResp?: WebSocketRequestResponsePair): void { + this.#webSockets().setWebSocketAutoResponse(maybeReqResp); } - setWebSocketAutoResponse(_maybeReqResp?: WebSocketRequestResponsePair): never { - throw new Error(HIBERNATION_UNIMPLEMENTED_MESSAGE); + getWebSocketAutoResponse(): WebSocketRequestResponsePair | null { + return this.#webSockets().getWebSocketAutoResponse(); } - getWebSocketAutoResponse(): never { - throw new Error(HIBERNATION_UNIMPLEMENTED_MESSAGE); + getWebSocketAutoResponseTimestamp(ws: WebSocket): Date | null { + return this.#webSockets().getWebSocketAutoResponseTimestamp(ws); } - getWebSocketAutoResponseTimestamp(_ws: WebSocket): never { - throw new Error(HIBERNATION_UNIMPLEMENTED_MESSAGE); + setHibernatableWebSocketEventTimeout(timeoutMs?: number): void { + this.#webSockets().setHibernatableWebSocketEventTimeout(timeoutMs); } - setHibernatableWebSocketEventTimeout(_timeoutMs?: number): never { - throw new Error(HIBERNATION_UNIMPLEMENTED_MESSAGE); + getHibernatableWebSocketEventTimeout(): number | null { + return this.#webSockets().getHibernatableWebSocketEventTimeout(); } - getHibernatableWebSocketEventTimeout(): never { - throw new Error(HIBERNATION_UNIMPLEMENTED_MESSAGE); + getTags(ws: WebSocket): string[] { + return this.#webSockets().getTags(ws); } - getTags(_ws: WebSocket): never { - throw new Error(HIBERNATION_UNIMPLEMENTED_MESSAGE); + #webSockets(): HibernatableWebSocketRegistry { + const webSockets = this.#options.webSockets; + if (webSockets === undefined) throw new Error("This Durable Object has no WebSocket runtime."); + return webSockets; } } diff --git a/src/api/global-scope.test.ts b/src/api/global-scope.test.ts index ae53da2..29cfd9f 100644 --- a/src/api/global-scope.test.ts +++ b/src/api/global-scope.test.ts @@ -396,7 +396,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 +404,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..a2eb754 100644 --- a/src/api/global-scope.ts +++ b/src/api/global-scope.ts @@ -49,6 +49,12 @@ import { type IoContext, } from "../io/io-context"; import { gateResponseBody } from "./http"; +import { + installWebSocketGlobals, + WebSocketRequestResponsePair, + type HibernatableWebSocketRegistry, + type RuntimeWebSocketPairConstructor, +} from "./web-socket"; /** * ← `AlarmInvocationInfo` (`api/global-scope.h:386-412`): "a jsg::Object used to @@ -304,6 +310,7 @@ export type ActorGlobalScopeOptions = { * refuses by name rather than reaching a `fetch` this package does not own. */ readonly fetch?: FetchPort | undefined; + readonly webSockets?: HibernatableWebSocketRegistry | undefined; }; /** Thrown where `globalOutbound` is absent. Asserted rather than skipped, so it cannot drift. */ @@ -335,11 +342,20 @@ export class ActorGlobalScope { readonly #readCurrentExternalEntry: (() => object | undefined) | undefined; readonly scheduler: Scheduler; readonly crypto: GatedCrypto; + declare readonly WebSocket: typeof globalThis.WebSocket; + declare readonly WebSocketPair: RuntimeWebSocketPairConstructor; + declare readonly WebSocketRequestResponsePair: typeof WebSocketRequestResponsePair; constructor(ctx: IoContext, options: ActorGlobalScopeOptions = {}) { this.#ctx = ctx; this.#fetch = options.fetch; this.#readCurrentExternalEntry = options.currentExternalEntry; + const unavailablePair = new Proxy(function WebSocketPair() {}, { + construct(): never { + throw new Error("WebSocketPair is unavailable on this unbound actor scope."); + }, + }) as unknown as RuntimeWebSocketPairConstructor; + installWebSocketGlobals(this, options.webSockets?.WebSocketPair ?? unavailablePair); this.scheduler = new Scheduler(this); this.crypto = new GatedCrypto( (op) => { @@ -489,6 +505,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: RuntimeWebSocketPairConstructor; + readonly WebSocketRequestResponsePair: typeof WebSocketRequestResponsePair; readonly currentExternalEntry?: object | undefined; }; @@ -502,6 +521,9 @@ export type ActorScopeBindings = { * scope instead. A single-actor host simply writes `() => scope`. */ export function actorScopeBindings(resolve: () => ActorGlobalScope): ActorScopeBindings { + const WebSocketPair = new Proxy(function WebSocketPair() {}, { + construct: () => Reflect.construct(resolve().WebSocketPair, []), + }) as unknown as RuntimeWebSocketPairConstructor; 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, + WebSocketRequestResponsePair, get currentExternalEntry(): object | undefined { return resolve().currentExternalEntry; }, diff --git a/src/api/hibernatable-web-socket.test.ts b/src/api/hibernatable-web-socket.test.ts index 643cd15..d9f683a 100644 --- a/src/api/hibernatable-web-socket.test.ts +++ b/src/api/hibernatable-web-socket.test.ts @@ -14,9 +14,12 @@ import type { RawWebSocket } from "./web-socket"; const timer: Timer = { now: () => Date.now(), afterDelay: (ms, signal) => - new Promise((resolve) => { + new Promise((resolve, reject) => { const handle = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => clearTimeout(handle)); + signal?.addEventListener("abort", () => { + clearTimeout(handle); + reject(signal.reason); + }); }), }; @@ -97,7 +100,8 @@ function recorder(): { const attached = vi.fn((socket: RawWebSocket, bytes: Uint8Array | null) => { const entry = entries.get(socket); if (entry === undefined) throw new Error("attachment mirrored before acceptance"); - entry.attachment = bytes === null ? undefined : bytes.slice(); + if (bytes === null) delete entry.attachment; + else entry.attachment = bytes.slice(); }); const autoResponse = vi.fn(); const closed = vi.fn((socket: RawWebSocket) => { @@ -184,7 +188,9 @@ describe("hibernation embedder contract", () => { container.state.acceptWebSocket(server, ["id"]); client.accept(); client.addEventListener("message", (event) => replies.push(String(event.data))); - container.state.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong")); + container.state.setWebSocketAutoResponse( + new container.globals.WebSocketRequestResponsePair("ping", "pong") as WebSocketRequestResponsePair, + ); }); expect(mirror.autoResponse).toHaveBeenLastCalledWith({ request: "ping", response: "pong" }); @@ -267,13 +273,14 @@ test("quiescence reports eviction state and gateHooks reach both gates", async ( }); expect(inside).toEqual({ armedTimers: 1, - pendingWaitUntil: 1, + pendingWaitUntil: 2, inputLockHeld: true, outputGateBroken: false, }); + await quiesce(2); expect(container.quiescence()).toEqual({ armedTimers: 1, - pendingWaitUntil: 1, + pendingWaitUntil: 2, inputLockHeld: false, outputGateBroken: false, }); @@ -281,6 +288,7 @@ test("quiescence reports eviction state and gateHooks reach both gates", async ( pending.resolve(); await container.run(() => container.globals.clearInterval(interval)); await container.drainWaitUntil(); + await quiesce(2); expect(container.quiescence()).toEqual({ armedTimers: 0, pendingWaitUntil: 0, diff --git a/src/api/web-socket.ts b/src/api/web-socket.ts index 24e0e0b..88d35da 100644 --- a/src/api/web-socket.ts +++ b/src/api/web-socket.ts @@ -1,53 +1,79 @@ /** - * ← 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; +}; + +export interface WebSocketRequestResponsePair { + readonly request: string; + readonly response: string; +} + +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; + } +} + +export const WebSocketRequestResponsePair: { + new (request: string, response: string): WebSocketRequestResponsePair; + readonly prototype: 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 interface RuntimeWebSocketPair { + 0: AcceptedWebSocket; + 1: AcceptedWebSocket; +} + +export interface RuntimeWebSocketPairConstructor { + new (): RuntimeWebSocketPair; } /** ← the `JSG_REQUIRE(!native.state.is(), ...)` at the head of `accept()`. */ @@ -55,162 +81,675 @@ 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; -/** 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]; +type SocketMode = "classic" | "hibernatable"; -/** - * ← `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 SocketMetadata = { + accepted?: { mode: SocketMode; registry?: HibernatableWebSocketRegistry }; + attachment?: Uint8Array; + hasAttachment: boolean; +}; + +const metadata = new WeakMap(); + +function socketMetadata(socket: object): SocketMetadata { + let value = metadata.get(socket); + if (value === undefined) { + value = { hasAttachment: false }; + 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" ? new TextEncoder().encode(value).byteLength + 5 : bytes.byteLength; + if (measuredBytes > MAX_ATTACHMENT_BYTES) { + throw new Error( + `A WebSocket 'attachment' cannot be larger than 16384 bytes.'attachment' was ${measuredBytes} bytes.`, + ); + } + const state = socketMetadata(socket); + state.attachment = bytes; + state.hasAttachment = true; + state.accepted?.registry?.attachmentChanged(socket, bytes); +} + +function deserializeAttachment(socket: RawWebSocket): unknown { + const state = socketMetadata(socket); + if (!state.hasAttachment) return null; + return deserializeValue("WebSocket attachment", state.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 data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer; + } + 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 = 0; + static readonly READY_STATE_OPEN = 1; + static readonly READY_STATE_CLOSING = 2; + static readonly READY_STATE_CLOSED = 3; + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + 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; + #mode: SocketMode | undefined; + #registry: HibernatableWebSocketRegistry | undefined; + #criticalSection: CriticalSection | undefined; #pump: Promise = Promise.resolve(); + #pending: { type: SocketEvent; event: Event }[] = []; + #readyState = 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, + options: { deferred?: boolean; 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 = options.pairState; + if (options.deferred !== true) 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.#mode === "hibernatable") throw new TypeError(HIBERNATION_AFTER_ACCEPT_MESSAGE); + if (this.#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.#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.#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); + } + + enableHibernation( + registry: HibernatableWebSocketRegistry, + rehydrate = false, + ctx: IoContext = this.#ctx, + ): void { + if (!rehydrate) { + if (this.#mode !== undefined) 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.#ctx = ctx; + this.#mode = "hibernatable"; + this.#registry = registry; + this.#pending = []; + } + + markPairUsed(): void { + this.#markPairUsed(); + } + + #markPairUsed(): void { + if (this.#pairState !== undefined) this.#pairState.used = true; + } + + #enableClassic(): void { + this.#mode = "classic"; + this.#criticalSection = this.#ctx.getCriticalSection(); + socketMetadata(this).accepted = { mode: "classic" }; + const pending = this.#pending; + this.#pending = []; + for (const item of pending) this.#deliverClassic(item.type, item.event); + } + + #receive(type: SocketEvent, event: Event): void { + if (type === "close") { + this.#receiveClose(event as CloseEvent); + return; + } + if (this.#mode === undefined) this.#pending.push({ type, event }); + else if (this.#mode === "classic") this.#deliverClassic(type, event); + else this.#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.#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); + } + } + } + if (this.#mode === undefined) this.#pending.push({ type: "close", event }); + else if (this.#mode === "classic") this.#deliverClassic("close", event); + else this.#registry!.receive(this, "close", event); + } + + #deliverClassic(type: SocketEvent, event: Event): 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 }), ); } - /** - * ← `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({ + READY_STATE_CONNECTING: 0, + READY_STATE_OPEN: 1, + READY_STATE_CLOSING: 2, + READY_STATE_CLOSED: 3, + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3, +})) { + 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 = { + socket: RawWebSocket; + 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: RuntimeWebSocketPairConstructor | 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(): RuntimeWebSocketPairConstructor { + this.#pairConstructor ??= new Proxy(function WebSocketPair() {}, { + construct: () => this.#createPair(), + }) as unknown as RuntimeWebSocketPairConstructor; + 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 32768 websockets can be accepted on a single Durable Object instance"); + } + const normalizedTags = normalizeTags(tags); + if (socket instanceof AcceptedWebSocket) socket.enableHibernation(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 WebSocketRequestResponsePair(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 604800000 ms."); + } + this.#eventTimeout = timeout; + } + + getHibernatableWebSocketEventTimeout(): number | null { + return this.#eventTimeout; + } + + attachmentChanged(socket: RawWebSocket, bytes: Uint8Array): void { + 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; + } + if (data instanceof Blob) { + this.#ctx.addWaitUntil( + data.arrayBuffer().then((buffer) => { + this.#schedule(() => this.#dispatch.message(socket, buffer)); + }), + ); + return; + } + const message = + typeof data === "string" ? data : (cloneMessageData(data) as ArrayBuffer); + 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 { + for (const type of ["message", "close", "error"] as const) { + socket.addEventListener(type, (event) => this.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 === undefined ? [] : [...value.tags]); + const state = socketMetadata(socket); + state.accepted = { mode: "hibernatable", registry: this }; + if (value.attachment !== undefined) { + state.attachment = value.attachment.slice(); + state.hasAttachment = true; + } + if (socket instanceof AcceptedWebSocket) socket.enableHibernation(this, true, this.#ctx); + else this.#listenRaw(socket); + this.#entries.push({ + socket, + tags, + ...(value.autoResponseTimestamp === undefined + ? {} + : { autoResponseTimestamp: value.autoResponseTimestamp }), + }); + } + + #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, { deferred: true, pairState }), + 1: new AcceptedWebSocket(this.#ctx, right, { deferred: true, 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); + socketMetadata(accepted).accepted = { mode: "classic" }; + return accepted; +} + +export function markWebSocketUsed(socket: RawWebSocket): void { + if (socket instanceof AcceptedWebSocket) socket.markPairUsed(); +} + +export function installWebSocketGlobals( + target: object, + pairConstructor: RuntimeWebSocketPairConstructor, +): void { + const constructor = + typeof globalThis.WebSocket === "function" ? globalThis.WebSocket : AcceptedWebSocket; + for (const [name, value] of Object.entries({ + READY_STATE_CONNECTING: 0, + READY_STATE_OPEN: 1, + READY_STATE_CLOSING: 2, + READY_STATE_CLOSED: 3, + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3, + })) { + 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", WebSocketRequestResponsePair); +} + +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: string[] | readonly string[] | undefined): 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 10 tags"); + } + const normalized = [...new Set(tags.map((tag) => String(tag)))]; + for (const tag of normalized) { + if (tag.length > MAX_TAG_LENGTH) { + throw new Error(`"${tag}" is longer than the max tag length (256 characters).`); + } + } + return normalized; +} + +function validateAutoResponseSize(side: "Request" | "Response", value: string): void { + const bytes = new TextEncoder().encode(value).byteLength; + if (bytes > MAX_AUTO_RESPONSE_BYTES) { + throw new RangeError( + `${side} cannot be larger than 2048 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 (new TextEncoder().encode(reason).byteLength > 123) { + throw new DOMException( + "WebSocket close reason must not be longer than 123 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..3f4f54e 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,14 @@ 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 { RawWebSocket, RehydratedWebSocket } from "./api/web-socket"; +export { + AcceptedWebSocket, + 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.ts b/src/io/io-context.ts index c3ad9db..6fc6475 100644 --- a/src/io/io-context.ts +++ b/src/io/io-context.ts @@ -688,6 +688,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 +1014,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..4796712 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"; @@ -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: { input?: InputGateHooks; output?: OutputGateHooks } = {}, + ) { 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( @@ -1335,6 +1371,7 @@ class ActorContainerImpl implements ActorContainer { // `ctx` and a dynamically-loaded source that destructured the seven names // are gated by one scope rather than two that could drift. globals: actorScopeBindings(() => this.globals), + webSockets: this.#webSockets, }); } @@ -1525,6 +1562,20 @@ class ActorContainerImpl implements ActorContainer { return this.#ctx.drainWaitUntil(); } + quiescence(): { + armedTimers: number; + pendingWaitUntil: number; + inputLockHeld: boolean; + outputGateBroken: boolean; + } { + 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 +1693,14 @@ class ActorContainerImpl implements ActorContainer { new AlarmInvocationInfo(scheduledTime, retryCount), ); } + + #runWebSocketHandler(name: string, socket: RawWebSocket, ...args: unknown[]): unknown { + const instance = this.#actor.classInstance; + if (instance.kind !== "running") return undefined; + const handler = (instance.instance as Record)[name]; + if (typeof handler !== "function") return undefined; + return Reflect.apply(handler, instance.instance, [socket, ...args]); + } } // ======================================================================================= From 498c530560c55e507cef5b08092e635039c1c9e7 Mon Sep 17 00:00:00 2001 From: Alex Nahas Date: Fri, 28 Aug 2026 11:59:03 -0700 Subject: [PATCH 4/7] Run the examples on hibernating Agents sockets Remove the hibernate:false overrides and the duplicate in-memory WebSocketPair now that the runtime supplies both. Keep only the embedder-owned Response 101 adapter, and teach the MessagePort bridge to announce socket halves that are already OPEN so Agents RPC does not wait forever for a synthetic open event. --- examples/extension/src/worker/actor.worker.ts | 7 +- .../src/worker/counter-child.worker.ts | 2 - examples/extension/src/worker/counter.ts | 1 - .../platform-shims/memory-websocket-pair.ts | 71 ++----------------- .../platform-shims/message-port-websocket.ts | 10 ++- .../vibe-platform/src/worker/workspace.ts | 1 - 6 files changed, 15 insertions(+), 77 deletions(-) diff --git a/examples/extension/src/worker/actor.worker.ts b/examples/extension/src/worker/actor.worker.ts index 2c9ad2b..e8fa7c3 100644 --- a/examples/extension/src/worker/actor.worker.ts +++ b/examples/extension/src/worker/actor.worker.ts @@ -51,7 +51,7 @@ 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, @@ -380,10 +380,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 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/memory-websocket-pair.ts b/examples/platform-shims/memory-websocket-pair.ts index d953185..0cf3426 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; - +/** 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 }); } } 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/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) { From 0821d6e326cdd1cab47a30a605dea1bebd0e1bf7 Mon Sep 17 00:00:00 2001 From: Alex Nahas Date: Fri, 28 Aug 2026 11:59:08 -0700 Subject: [PATCH 5/7] Document the socket eviction contract Describe the host mirror and rehydration handoff, the non-blocking quiescence signal, and the measured pair and close semantics. Record the breaking API replacement as a minor changeset for the 0.5.0 release. --- .changeset/hibernatable-websockets.md | 11 +++++++++++ README.md | 27 ++++++++++++++++++++++++--- docs/decisions.md | 24 +++++++++++++++++------- docs/gating-coverage.md | 2 +- 4 files changed, 53 insertions(+), 11 deletions(-) create mode 100644 .changeset/hibernatable-websockets.md 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/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 From 62db7d1046afa8e40263ae2b71a6ffd9899d52ab Mon Sep 17 00:00:00 2001 From: Alex Nahas Date: Fri, 28 Aug 2026 13:22:41 -0700 Subject: [PATCH 6/7] Tighten WebSocket lifecycle ownership Represent socket acceptance and delivery as explicit states so registry-dependent behavior cannot exist without its registry. Route raw listeners through the current registry after a hibernation rebuild, and keep attachment serialization local after close instead of calling a host that no longer owns the socket. Reuse Workers platform types and one conformance mirror to remove duplicated adapters and unsafe casts. --- conformance/browser/actor.worker.ts | 46 +-- conformance/browser/host.ts | 2 +- conformance/hibernation-host.ts | 38 +++ conformance/node/host.ts | 86 ++--- .../platform-shims/memory-websocket-pair.ts | 4 +- src/api/actor-state.test.ts | 11 +- src/api/actor-state.ts | 34 +- src/api/global-scope.test.ts | 14 +- src/api/global-scope.ts | 36 +- src/api/hibernatable-web-socket.test.ts | 91 ++++- src/api/web-socket.ts | 317 ++++++++++-------- src/index.ts | 3 +- src/server/actor-container.ts | 21 +- 13 files changed, 397 insertions(+), 306 deletions(-) create mode 100644 conformance/hibernation-host.ts diff --git a/conformance/browser/actor.worker.ts b/conformance/browser/actor.worker.ts index 9d5a61a..ed7b9b6 100644 --- a/conformance/browser/actor.worker.ts +++ b/conformance/browser/actor.worker.ts @@ -64,9 +64,6 @@ import { type FacetTree, type IsolateChannelFactory, type LoadIsolateRequest, - type HibernationHost, - type RawWebSocket, - type RehydratedWebSocket, type WorkerSource, type WorkerStubChannel, } from "../../src/index"; @@ -80,14 +77,15 @@ import { type SqliteWasmHost, } from "../../backends/sqlite-wasm"; import { Probe } from "../fixtures/probe"; -import type { ActorBoot, ActorRpc, SupervisorRpc } from "./protocol"; -import { installPool, timer, UNIQUE_KEY } from "./substrate"; +import { HibernationMirror } from "../hibernation-host"; import { installWebSocketUpgradeGlobals, upgradeWebSocket, webSocketUpgradeRequest, type UpgradeWebSocket, } from "../websocket-upgrade"; +import type { ActorBoot, ActorRpc, SupervisorRpc } from "./protocol"; +import { installPool, timer, UNIQUE_KEY } from "./substrate"; type Session = ReturnType>; @@ -116,42 +114,6 @@ let placing: Promise | undefined; /** The page. */ let peer: Session | undefined; -class BrowserHibernationHost 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("Browser lane: attachment preceded socket acceptance."); - } - this.#entries.set(socket, { - socket, - tags: entry.tags, - ...(bytes === null ? {} : { attachment: bytes.slice() }), - ...(entry.autoResponseTimestamp === undefined - ? {} - : { autoResponseTimestamp: entry.autoResponseTimestamp }), - }); - } - - 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()]; - } -} - type SocketClose = { code: number; reason: string; wasClean: boolean }; type ClientRecord = { socket: UpgradeWebSocket; @@ -161,7 +123,7 @@ type ClientRecord = { closeWaiters: ((close: SocketClose) => void)[]; }; -const hibernation = new BrowserHibernationHost(); +const hibernation = new HibernationMirror(); const clients = new Map(); let clientCounter = 0; diff --git a/conformance/browser/host.ts b/conformance/browser/host.ts index 7f999e8..7b03c3f 100644 --- a/conformance/browser/host.ts +++ b/conformance/browser/host.ts @@ -114,7 +114,7 @@ class BrowserClientSocket implements LaneClientSocket { async send(data: string | ArrayBuffer | ArrayBufferView): Promise { const message = ArrayBuffer.isView(data) - ? (data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer) + ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice().buffer : data; await this.rpc.socketSend(this.id, message); } diff --git a/conformance/hibernation-host.ts b/conformance/hibernation-host.ts new file mode 100644 index 0000000..e141b4d --- /dev/null +++ b/conformance/hibernation-host.ts @@ -0,0 +1,38 @@ +import type { + HibernationHost, + RawWebSocket, + RehydratedWebSocket, +} from "../src/index"; + +type MirroredWebSocket = RehydratedWebSocket & { tags: readonly string[] }; + +/** In-memory socket state shared by the Node and browser reference embedders. */ +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/conformance/node/host.ts b/conformance/node/host.ts index 6dc5206..0cf256b 100644 --- a/conformance/node/host.ts +++ b/conformance/node/host.ts @@ -32,21 +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 { RawWebSocket, RehydratedWebSocket, Timer } from "../../src/index"; import { AlarmScheduler, createActorContainer, installWebSocketGlobals, -} from "../../src/index"; -import type { - ActorContainer, - ActorEntry, - FacetHandle, - FacetHost, - FacetId, - FacetStartRequest, - FacetTree, - HibernationHost, + type ActorContainer, + type ActorEntry, + type FacetHandle, + type FacetHost, + type FacetId, + type FacetStartRequest, + type FacetTree, + type Timer, } from "../../src/index"; import type { ActorClassChannel, @@ -55,7 +52,6 @@ import type { } from "../../src/io/io-channels"; import type { WorkerSource } from "../../src/io/worker-source"; import type { IsolateChannelFactory, LoadIsolateRequest } from "../../src/api/worker-loader"; -import type { RuntimeWebSocketPairConstructor } from "../../src/api/web-socket"; import { asLoopbackDurableObjectClass, LoopbackDurableObjectClass, @@ -68,6 +64,7 @@ import type { ProbeActor, } from "../host"; import { Probe } from "../fixtures/probe"; +import { HibernationMirror } from "../hibernation-host"; import { installWebSocketUpgradeGlobals, upgradeWebSocket, @@ -181,19 +178,22 @@ globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { return container.globals.fetch(input, init); }) as typeof globalThis.fetch; -const actorWebSocketPair = new Proxy(class WebSocketPair {}, { - construct(): object { - const pair = current.getStore()?.globals.WebSocketPair; - if (pair === undefined) { - throw new Error("Node lane: WebSocketPair was constructed outside an actor event."); - } - return Reflect.construct(pair, []); +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 as unknown as RuntimeWebSocketPairConstructor, ); +installWebSocketGlobals(globalThis, ActorWebSocketPair); installWebSocketUpgradeGlobals(); /** @@ -509,40 +509,6 @@ type Placement = { readonly stub: object; }; -class NodeHibernationHost 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("Node lane: attachment preceded socket acceptance."); - this.#entries.set(socket, { - socket, - tags: entry.tags, - ...(bytes === null ? {} : { attachment: bytes.slice() }), - ...(entry.autoResponseTimestamp === undefined - ? {} - : { autoResponseTimestamp: entry.autoResponseTimestamp }), - }); - } - - 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()]; - } -} - class NodeClientSocket implements LaneClientSocket { readonly #messages: LaneSocketMessage[] = []; readonly #messageWaiters: ((message: LaneSocketMessage) => void)[] = []; @@ -760,12 +726,12 @@ function alarmScheduler(): Promise { } const live = new Map(); -const socketHosts = new Map(); +const socketHosts = new Map(); -function socketHost(name: string): NodeHibernationHost { +function socketHost(name: string): HibernationMirror { const existing = socketHosts.get(name); if (existing !== undefined) return existing; - const host = new NodeHibernationHost(); + const host = new HibernationMirror(); socketHosts.set(name, host); return host; } diff --git a/examples/platform-shims/memory-websocket-pair.ts b/examples/platform-shims/memory-websocket-pair.ts index 0cf3426..21e4029 100644 --- a/examples/platform-shims/memory-websocket-pair.ts +++ b/examples/platform-shims/memory-websocket-pair.ts @@ -5,7 +5,7 @@ export type UpgradeWebSocket = EventTarget & RawWebSocket & { readonly readyState: number; }; -type UpgradeResponseInit = Omit & { webSocket?: UpgradeWebSocket }; +type UpgradeResponseInit = ResponseInit & { webSocket?: UpgradeWebSocket }; /** Install the Response-101 half; `installActorScope` supplies the runtime's WebSocketPair. */ export function installWebSocketUpgradeResponse(): void { @@ -26,7 +26,7 @@ export function installWebSocketUpgradeResponse(): 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/src/api/actor-state.test.ts b/src/api/actor-state.test.ts index f51a1b5..da15f7b 100644 --- a/src/api/actor-state.test.ts +++ b/src/api/actor-state.test.ts @@ -62,6 +62,7 @@ import { FACET_TREE_MAX_DEPTH, 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 { @@ -181,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 = []; @@ -207,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 {} }, @@ -215,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(() => {}); @@ -804,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( diff --git a/src/api/actor-state.ts b/src/api/actor-state.ts index 62c3986..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`, @@ -998,7 +996,7 @@ export type DurableObjectStateOptions = { * `DurableObjectState.globals`. */ globals: ActorScopeBindings; - webSockets?: HibernatableWebSocketRegistry; + webSockets: HibernatableWebSocketRegistry; }; /** The type passed as the first parameter to a Durable Object class's constructor. */ @@ -1138,42 +1136,36 @@ export class DurableObjectState implements globalThis.DurableObjectState { } acceptWebSocket(ws: WebSocket, tags?: string[]): void { - this.#webSockets().acceptWebSocket(ws, tags); + this.#options.webSockets.acceptWebSocket(ws, tags); } getWebSockets(tag?: string): WebSocket[] { return tag === undefined - ? this.#webSockets().getWebSockets() - : this.#webSockets().getWebSockets(tag); + ? this.#options.webSockets.getWebSockets() + : this.#options.webSockets.getWebSockets(tag); } setWebSocketAutoResponse(maybeReqResp?: WebSocketRequestResponsePair): void { - this.#webSockets().setWebSocketAutoResponse(maybeReqResp); + this.#options.webSockets.setWebSocketAutoResponse(maybeReqResp); } getWebSocketAutoResponse(): WebSocketRequestResponsePair | null { - return this.#webSockets().getWebSocketAutoResponse(); + return this.#options.webSockets.getWebSocketAutoResponse(); } getWebSocketAutoResponseTimestamp(ws: WebSocket): Date | null { - return this.#webSockets().getWebSocketAutoResponseTimestamp(ws); + return this.#options.webSockets.getWebSocketAutoResponseTimestamp(ws); } setHibernatableWebSocketEventTimeout(timeoutMs?: number): void { - this.#webSockets().setHibernatableWebSocketEventTimeout(timeoutMs); + this.#options.webSockets.setHibernatableWebSocketEventTimeout(timeoutMs); } getHibernatableWebSocketEventTimeout(): number | null { - return this.#webSockets().getHibernatableWebSocketEventTimeout(); + return this.#options.webSockets.getHibernatableWebSocketEventTimeout(); } getTags(ws: WebSocket): string[] { - return this.#webSockets().getTags(ws); - } - - #webSockets(): HibernatableWebSocketRegistry { - const webSockets = this.#options.webSockets; - if (webSockets === undefined) throw new Error("This Durable Object has no WebSocket runtime."); - return webSockets; + return this.#options.webSockets.getTags(ws); } } diff --git a/src/api/global-scope.test.ts b/src/api/global-scope.test.ts index 29cfd9f..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", () => { diff --git a/src/api/global-scope.ts b/src/api/global-scope.ts index a2eb754..795f1e5 100644 --- a/src/api/global-scope.ts +++ b/src/api/global-scope.ts @@ -53,9 +53,10 @@ import { installWebSocketGlobals, WebSocketRequestResponsePair, type HibernatableWebSocketRegistry, - type RuntimeWebSocketPairConstructor, } from "./web-socket"; +type WebSocketPairConstructor = typeof WebSocketPair; + /** * ← `AlarmInvocationInfo` (`api/global-scope.h:386-412`): "a jsg::Object used to * pass alarm invocation info to an alarm handler." @@ -294,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; /** @@ -310,7 +312,6 @@ export type ActorGlobalScopeOptions = { * refuses by name rather than reaching a `fetch` this package does not own. */ readonly fetch?: FetchPort | undefined; - readonly webSockets?: HibernatableWebSocketRegistry | undefined; }; /** Thrown where `globalOutbound` is absent. Asserted rather than skipped, so it cannot drift. */ @@ -343,19 +344,14 @@ export class ActorGlobalScope { readonly scheduler: Scheduler; readonly crypto: GatedCrypto; declare readonly WebSocket: typeof globalThis.WebSocket; - declare readonly WebSocketPair: RuntimeWebSocketPairConstructor; + 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; - const unavailablePair = new Proxy(function WebSocketPair() {}, { - construct(): never { - throw new Error("WebSocketPair is unavailable on this unbound actor scope."); - }, - }) as unknown as RuntimeWebSocketPairConstructor; - installWebSocketGlobals(this, options.webSockets?.WebSocketPair ?? unavailablePair); + installWebSocketGlobals(this, options.webSockets.WebSocketPair); this.scheduler = new Scheduler(this); this.crypto = new GatedCrypto( (op) => { @@ -506,7 +502,7 @@ export type ActorScopeBindings = { readonly fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; readonly crypto: Crypto; readonly WebSocket: typeof globalThis.WebSocket; - readonly WebSocketPair: RuntimeWebSocketPairConstructor; + readonly WebSocketPair: WebSocketPairConstructor; readonly WebSocketRequestResponsePair: typeof WebSocketRequestResponsePair; readonly currentExternalEntry?: object | undefined; }; @@ -521,9 +517,13 @@ export type ActorScopeBindings = { * scope instead. A single-actor host simply writes `() => scope`. */ export function actorScopeBindings(resolve: () => ActorGlobalScope): ActorScopeBindings { - const WebSocketPair = new Proxy(function WebSocketPair() {}, { - construct: () => Reflect.construct(resolve().WebSocketPair, []), - }) as unknown as RuntimeWebSocketPairConstructor; + 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: { @@ -541,7 +541,7 @@ export function actorScopeBindings(resolve: () => ActorGlobalScope): ActorScopeB fetch: (input, init) => resolve().fetch(input, init), crypto: scopeCrypto(resolve), WebSocket: globalThis.WebSocket, - WebSocketPair, + WebSocketPair: BoundWebSocketPair, WebSocketRequestResponsePair, get currentExternalEntry(): object | undefined { return resolve().currentExternalEntry; @@ -554,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 @@ -606,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 index d9f683a..eb768a4 100644 --- a/src/api/hibernatable-web-socket.test.ts +++ b/src/api/hibernatable-web-socket.test.ts @@ -52,6 +52,52 @@ type SocketLike = WebSocket & { 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[]; @@ -78,6 +124,16 @@ class SocketActor { } } +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[]; @@ -147,11 +203,12 @@ describe("hibernation embedder contract", () => { 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!], + webSockets: [persisted], }); expect(second.actor.constructorSockets).toEqual([ { @@ -203,6 +260,38 @@ describe("hibernation embedder contract", () => { 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 () => { diff --git a/src/api/web-socket.ts b/src/api/web-socket.ts index 88d35da..9415e4b 100644 --- a/src/api/web-socket.ts +++ b/src/api/web-socket.ts @@ -33,10 +33,7 @@ export type RehydratedWebSocket = { autoResponseTimestamp?: number; }; -export interface WebSocketRequestResponsePair { - readonly request: string; - readonly response: string; -} +type WebSocketPairConstructor = typeof WebSocketPair; class WebSocketRequestResponsePairImpl implements WebSocketRequestResponsePair { readonly #request: string; @@ -56,25 +53,21 @@ class WebSocketRequestResponsePairImpl implements WebSocketRequestResponsePair { } } -export const WebSocketRequestResponsePair: { - new (request: string, response: string): WebSocketRequestResponsePair; - readonly prototype: 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.", - ); - }, -}); +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 }; -export interface RuntimeWebSocketPair { +type RuntimeWebSocketPair = { 0: AcceptedWebSocket; 1: AcceptedWebSocket; -} - -export interface RuntimeWebSocketPairConstructor { - new (): RuntimeWebSocketPair; -} +}; /** ← the `JSG_REQUIRE(!native.state.is(), ...)` at the head of `accept()`. */ export const ALREADY_ACCEPTED_MESSAGE = @@ -94,20 +87,42 @@ 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(); const SOCKET_EVENTS = ["open", "message", "close", "error"] as const; type SocketEvent = (typeof SOCKET_EVENTS)[number]; -type SocketMode = "classic" | "hibernatable"; 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?: { mode: SocketMode; registry?: HibernatableWebSocketRegistry }; + accepted?: SocketAcceptance; attachment?: Uint8Array; - hasAttachment: boolean; + rawListenersInstalled?: true; }; const metadata = new WeakMap(); @@ -115,7 +130,7 @@ const metadata = new WeakMap(); function socketMetadata(socket: object): SocketMetadata { let value = metadata.get(socket); if (value === undefined) { - value = { hasAttachment: false }; + value = {}; metadata.set(socket, value); } return value; @@ -159,22 +174,23 @@ function serializeAttachment(socket: RawWebSocket, value: unknown): void { // 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" ? new TextEncoder().encode(value).byteLength + 5 : bytes.byteLength; + 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 16384 bytes.'attachment' was ${measuredBytes} bytes.`, + `A WebSocket 'attachment' cannot be larger than ${MAX_ATTACHMENT_BYTES} bytes.'attachment' was ${measuredBytes} bytes.`, ); } const state = socketMetadata(socket); state.attachment = bytes; - state.hasAttachment = true; - state.accepted?.registry?.attachmentChanged(socket, bytes); + if (state.accepted?.mode === "hibernatable") { + state.accepted.registry.attachmentChanged(socket, bytes); + } } function deserializeAttachment(socket: RawWebSocket): unknown { - const state = socketMetadata(socket); - if (!state.hasAttachment) return null; - return deserializeValue("WebSocket attachment", state.attachment!); + const attachment = socketMetadata(socket).attachment; + if (attachment === undefined) return null; + return deserializeValue("WebSocket attachment", attachment); } function serializeAttachmentMethod(this: unknown, value?: unknown): void { @@ -194,7 +210,7 @@ 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 data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer; + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice().buffer; } return String(data); } @@ -216,14 +232,14 @@ class MemoryWebSocketEndpoint extends EventTarget implements RawWebSocket { /** One public socket identity, in classic or hibernatable mode after acceptance. */ export class AcceptedWebSocket extends EventTarget implements RawWebSocket, WebSocket { - static readonly READY_STATE_CONNECTING = 0; - static readonly READY_STATE_OPEN = 1; - static readonly READY_STATE_CLOSING = 2; - static readonly READY_STATE_CLOSED = 3; - static readonly CONNECTING = 0; - static readonly OPEN = 1; - static readonly CLOSING = 2; - static readonly CLOSED = 3; + 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; @@ -241,12 +257,10 @@ export class AcceptedWebSocket extends EventTarget implements RawWebSocket, WebS #ctx: IoContext; readonly #socket: RawWebSocket; readonly #pairState: PairState | undefined; - #mode: SocketMode | undefined; - #registry: HibernatableWebSocketRegistry | undefined; - #criticalSection: CriticalSection | undefined; + #delivery: SocketDelivery = { mode: "pending" }; #pump: Promise = Promise.resolve(); #pending: { type: SocketEvent; event: Event }[] = []; - #readyState = AcceptedWebSocket.OPEN; + #readyState: number = AcceptedWebSocket.OPEN; #ownClose = false; #peerClose = false; #binaryType: "blob" | "arraybuffer" = "blob"; @@ -256,16 +270,12 @@ export class AcceptedWebSocket extends EventTarget implements RawWebSocket, WebS onclose: ((event: CloseEvent) => void) | null = null; onerror: ((event: Event) => void) | null = null; - constructor( - ctx: IoContext, - socket: RawWebSocket, - options: { deferred?: boolean; pairState?: PairState } = {}, - ) { + constructor(ctx: IoContext, socket: RawWebSocket, pairState?: PairState) { super(); this.#ctx = ctx; this.#socket = socket; - this.#pairState = options.pairState; - if (options.deferred !== true) this.#enableClassic(); + this.#pairState = pairState; + if (pairState === undefined) this.#enableClassic(); for (const type of SOCKET_EVENTS) { socket.addEventListener(type, (event: Event) => { this.#receive(type, event); @@ -286,14 +296,16 @@ export class AcceptedWebSocket extends EventTarget implements RawWebSocket, WebS } accept(): void { - if (this.#mode === "hibernatable") throw new TypeError(HIBERNATION_AFTER_ACCEPT_MESSAGE); - if (this.#mode === "classic") throw new Error(ALREADY_ACCEPTED_MESSAGE); + 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.#mode === "hibernatable" && this.#ownClose) { + 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; @@ -303,7 +315,9 @@ export class AcceptedWebSocket extends EventTarget implements RawWebSocket, WebS close(code?: number, reason = ""): void { if (this.#readyState === AcceptedWebSocket.CLOSED || this.#ownClose) return; - if (this.#mode === "hibernatable" || this.#pairState !== undefined) validateClose(code, reason); + if (this.#delivery.mode === "hibernatable" || this.#pairState !== undefined) { + validateClose(code, reason); + } this.#markPairUsed(); this.#ownClose = true; this.#readyState = this.#peerClose ? AcceptedWebSocket.CLOSED : AcceptedWebSocket.CLOSING; @@ -319,24 +333,27 @@ export class AcceptedWebSocket extends EventTarget implements RawWebSocket, WebS return deserializeAttachment(this); } - enableHibernation( - registry: HibernatableWebSocketRegistry, - rehydrate = false, - ctx: IoContext = this.#ctx, - ): void { - if (!rehydrate) { - if (this.#mode !== undefined) 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; - } + 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.#mode = "hibernatable"; - this.#registry = registry; + this.#delivery = { mode: "hibernatable", registry }; this.#pending = []; } @@ -349,12 +366,17 @@ export class AcceptedWebSocket extends EventTarget implements RawWebSocket, WebS } #enableClassic(): void { - this.#mode = "classic"; - this.#criticalSection = this.#ctx.getCriticalSection(); + 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); + for (const item of pending) { + this.#deliverClassic(item.type, item.event, delivery.criticalSection); + } } #receive(type: SocketEvent, event: Event): void { @@ -362,9 +384,11 @@ export class AcceptedWebSocket extends EventTarget implements RawWebSocket, WebS this.#receiveClose(event as CloseEvent); return; } - if (this.#mode === undefined) this.#pending.push({ type, event }); - else if (this.#mode === "classic") this.#deliverClassic(type, event); - else this.#registry!.receive(this, type, event); + 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 { @@ -373,7 +397,7 @@ export class AcceptedWebSocket extends EventTarget implements RawWebSocket, WebS } else { this.#peerClose = true; this.#readyState = AcceptedWebSocket.CLOSING; - if (this.#mode === "classic" && this.#pairState !== undefined) { + 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) { @@ -383,19 +407,25 @@ export class AcceptedWebSocket extends EventTarget implements RawWebSocket, WebS } } } - if (this.#mode === undefined) this.#pending.push({ type: "close", event }); - else if (this.#mode === "classic") this.#deliverClassic("close", event); - else this.#registry!.receive(this, "close", event); + 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): void { + #deliverClassic( + type: SocketEvent, + event: Event, + criticalSection: CriticalSection | undefined, + ): void { this.#ctx.addWaitUntil( this.#ctx.run(() => { const delivered = cloneEventFor(type, event); this.dispatchEvent(delivered); const handler = this[`on${type}`] as ((event: Event) => void) | null; handler?.(delivered); - }, { input: this.#criticalSection }), + }, { input: criticalSection }), ); } @@ -409,16 +439,7 @@ export class AcceptedWebSocket extends EventTarget implements RawWebSocket, WebS } } -for (const [name, value] of Object.entries({ - READY_STATE_CONNECTING: 0, - READY_STATE_OPEN: 1, - READY_STATE_CLOSING: 2, - READY_STATE_CLOSED: 3, - CONNECTING: 0, - OPEN: 1, - CLOSING: 2, - CLOSED: 3, -})) { +for (const [name, value] of Object.entries(WEB_SOCKET_READY_STATES)) { Object.defineProperty(AcceptedWebSocket.prototype, name, { value, enumerable: true }); } @@ -429,8 +450,8 @@ type HandlerDispatch = { }; type RegistryEntry = { - socket: RawWebSocket; - tags: string[]; + readonly socket: RawWebSocket; + readonly tags: string[]; autoResponseTimestamp?: number; }; @@ -441,7 +462,7 @@ export class HibernatableWebSocketRegistry { readonly #entries: RegistryEntry[] = []; #autoResponse: WebSocketRequestResponsePair | null = null; #eventTimeout: number | null = null; - #pairConstructor: RuntimeWebSocketPairConstructor | undefined; + #pairConstructor: WebSocketPairConstructor | undefined; constructor( ctx: IoContext, @@ -455,10 +476,15 @@ export class HibernatableWebSocketRegistry { for (const value of rehydrated) this.#rehydrate(value); } - get WebSocketPair(): RuntimeWebSocketPairConstructor { - this.#pairConstructor ??= new Proxy(function WebSocketPair() {}, { - construct: () => this.#createPair(), - }) as unknown as RuntimeWebSocketPairConstructor; + 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; } @@ -471,10 +497,12 @@ export class HibernatableWebSocketRegistry { 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 32768 websockets can be accepted on a single Durable Object instance"); + 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.enableHibernation(this); + if (socket instanceof AcceptedWebSocket) socket.acceptHibernation(this); else this.#listenRaw(socket); state.accepted = { mode: "hibernatable", registry: this }; this.#entries.push({ socket, tags: normalizedTags }); @@ -529,7 +557,9 @@ export class HibernatableWebSocketRegistry { getWebSocketAutoResponse(): WebSocketRequestResponsePair | null { const pair = this.#autoResponse; - return pair === null ? null : new WebSocketRequestResponsePair(pair.request, pair.response); + return pair === null + ? null + : new RuntimeWebSocketRequestResponsePair(pair.request, pair.response); } getWebSocketAutoResponseTimestamp(socket: RawWebSocket): Date | null { @@ -561,7 +591,7 @@ export class HibernatableWebSocketRegistry { } const timeout = Math.trunc(number); if (timeout > MAX_EVENT_TIMEOUT) { - throw new Error("Event timeout should not exceed 604800000 ms."); + throw new Error(`Event timeout should not exceed ${MAX_EVENT_TIMEOUT} ms.`); } this.#eventTimeout = timeout; } @@ -571,7 +601,9 @@ export class HibernatableWebSocketRegistry { } attachmentChanged(socket: RawWebSocket, bytes: Uint8Array): void { - this.#host?.attachment(socket, bytes); + if (this.#entries.some((entry) => entry.socket === socket)) { + this.#host?.attachment(socket, bytes); + } } receive(socket: RawWebSocket, type: SocketEvent, event: Event): void { @@ -584,16 +616,15 @@ export class HibernatableWebSocketRegistry { socket.send(this.#autoResponse.response); return; } - if (data instanceof Blob) { + const message = cloneMessageData(data); + if (message instanceof Blob) { this.#ctx.addWaitUntil( - data.arrayBuffer().then((buffer) => { + message.arrayBuffer().then((buffer) => { this.#schedule(() => this.#dispatch.message(socket, buffer)); }), ); return; } - const message = - typeof data === "string" ? data : (cloneMessageData(data) as ArrayBuffer); this.#schedule(() => this.#dispatch.message(socket, message)); return; } @@ -620,8 +651,16 @@ export class HibernatableWebSocketRegistry { } #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) => this.receive(socket, type, event)); + socket.addEventListener(type, (event) => { + const accepted = socketMetadata(socket).accepted; + if (accepted?.mode === "hibernatable") { + accepted.registry.receive(socket, type, event); + } + }); } } @@ -630,22 +669,19 @@ export class HibernatableWebSocketRegistry { if (!isRawWebSocket(socket)) { throw new TypeError("ActorContainerOptions.webSockets contains a non-WebSocket value."); } - const tags = normalizeTags(value.tags === undefined ? [] : [...value.tags]); + const tags = normalizeTags(value.tags); const state = socketMetadata(socket); state.accepted = { mode: "hibernatable", registry: this }; if (value.attachment !== undefined) { state.attachment = value.attachment.slice(); - state.hasAttachment = true; } - if (socket instanceof AcceptedWebSocket) socket.enableHibernation(this, true, this.#ctx); + if (socket instanceof AcceptedWebSocket) socket.rehydrateHibernation(this, this.#ctx); else this.#listenRaw(socket); - this.#entries.push({ - socket, - tags, - ...(value.autoResponseTimestamp === undefined - ? {} - : { autoResponseTimestamp: value.autoResponseTimestamp }), - }); + const entry: RegistryEntry = { socket, tags }; + if (value.autoResponseTimestamp !== undefined) { + entry.autoResponseTimestamp = value.autoResponseTimestamp; + } + this.#entries.push(entry); } #createPair(): RuntimeWebSocketPair { @@ -655,8 +691,8 @@ export class HibernatableWebSocketRegistry { left.peer = right; right.peer = left; return { - 0: new AcceptedWebSocket(this.#ctx, left, { deferred: true, pairState }), - 1: new AcceptedWebSocket(this.#ctx, right, { deferred: true, pairState }), + 0: new AcceptedWebSocket(this.#ctx, left, pairState), + 1: new AcceptedWebSocket(this.#ctx, right, pairState), }; } } @@ -670,7 +706,6 @@ export function acceptWebSocket(ctx: IoContext, socket: RawWebSocket): AcceptedW } state.accepted = { mode: "classic" }; const accepted = new AcceptedWebSocket(ctx, socket); - socketMetadata(accepted).accepted = { mode: "classic" }; return accepted; } @@ -680,20 +715,10 @@ export function markWebSocketUsed(socket: RawWebSocket): void { export function installWebSocketGlobals( target: object, - pairConstructor: RuntimeWebSocketPairConstructor, + pairConstructor: WebSocketPairConstructor, ): void { - const constructor = - typeof globalThis.WebSocket === "function" ? globalThis.WebSocket : AcceptedWebSocket; - for (const [name, value] of Object.entries({ - READY_STATE_CONNECTING: 0, - READY_STATE_OPEN: 1, - READY_STATE_CLOSING: 2, - READY_STATE_CLOSED: 3, - CONNECTING: 0, - OPEN: 1, - CLOSING: 2, - CLOSED: 3, - })) { + const constructor = globalThis.WebSocket; + for (const [name, value] of Object.entries(WEB_SOCKET_READY_STATES)) { defineValue(constructor, name, value); defineValue(constructor.prototype, name, value); } @@ -701,7 +726,7 @@ export function installWebSocketGlobals( defineValue(constructor.prototype, "deserializeAttachment", deserializeAttachmentMethod); defineValue(target, "WebSocket", constructor); defineValue(target, "WebSocketPair", pairConstructor); - defineValue(target, "WebSocketRequestResponsePair", WebSocketRequestResponsePair); + defineValue(target, "WebSocketRequestResponsePair", RuntimeWebSocketRequestResponsePair); } function defineValue(target: object, name: string, value: unknown): void { @@ -710,7 +735,7 @@ function defineValue(target: object, name: string, value: unknown): void { Object.defineProperty(target, name, { configurable: true, writable: true, value }); } -function normalizeTags(tags: string[] | readonly string[] | undefined): string[] { +function normalizeTags(tags: unknown): string[] { if (tags === undefined) return []; if (!Array.isArray(tags)) { throw new TypeError( @@ -718,22 +743,24 @@ function normalizeTags(tags: string[] | readonly string[] | undefined): string[] ); } if (tags.length > MAX_TAGS) { - throw new Error("a Hibernatable WebSocket cannot have more than 10 tags"); + throw new Error(`a Hibernatable WebSocket cannot have more than ${MAX_TAGS} tags`); } - const normalized = [...new Set(tags.map((tag) => String(tag)))]; + 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 (256 characters).`); + 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 = new TextEncoder().encode(value).byteLength; + const bytes = textEncoder.encode(value).byteLength; if (bytes > MAX_AUTO_RESPONSE_BYTES) { throw new RangeError( - `${side} cannot be larger than 2048 bytes. A ${side.toLowerCase()} of size ${bytes} was provided.`, + `${side} cannot be larger than ${MAX_AUTO_RESPONSE_BYTES} bytes. A ${side.toLowerCase()} of size ${bytes} was provided.`, ); } } @@ -742,9 +769,9 @@ 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 (new TextEncoder().encode(reason).byteLength > 123) { + if (textEncoder.encode(reason).byteLength > MAX_CLOSE_REASON_BYTES) { throw new DOMException( - "WebSocket close reason must not be longer than 123 bytes when UTF-8 encoded.", + `WebSocket close reason must not be longer than ${MAX_CLOSE_REASON_BYTES} bytes when UTF-8 encoded.`, "SyntaxError", ); } diff --git a/src/index.ts b/src/index.ts index 3f4f54e..f43390b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -203,9 +203,8 @@ export { installActorScope, NO_GLOBAL_OUTBOUND_MESSAGE, } from "./api/global-scope"; -export type { RawWebSocket, RehydratedWebSocket } from "./api/web-socket"; +export type { AcceptedWebSocket, RawWebSocket, RehydratedWebSocket } from "./api/web-socket"; export { - AcceptedWebSocket, ALREADY_ACCEPTED_MESSAGE, WebSocketRequestResponsePair, installWebSocketGlobals, diff --git a/src/server/actor-container.ts b/src/server/actor-container.ts index 4796712..0eaceaf 100644 --- a/src/server/actor-container.ts +++ b/src/server/actor-container.ts @@ -237,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 @@ -851,7 +851,7 @@ class ActorImpl implements Actor { constructor( isFacet: boolean, - hooks: { input?: InputGateHooks; output?: OutputGateHooks } = {}, + hooks: ActorContainerOptions["gateHooks"] = {}, ) { this.#isFacet = isFacet; this.#inputGate = new InputGate(hooks.input); @@ -1368,7 +1368,7 @@ 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, @@ -1562,12 +1562,7 @@ class ActorContainerImpl implements ActorContainer { return this.#ctx.drainWaitUntil(); } - quiescence(): { - armedTimers: number; - pendingWaitUntil: number; - inputLockHeld: boolean; - outputGateBroken: boolean; - } { + quiescence() { return { armedTimers: this.#ctx.getTimeoutCount(), pendingWaitUntil: this.#ctx.waitUntilTaskCount(), @@ -1694,10 +1689,14 @@ class ActorContainerImpl implements ActorContainer { ); } - #runWebSocketHandler(name: string, socket: RawWebSocket, ...args: unknown[]): unknown { + #runWebSocketHandler( + name: "webSocketMessage" | "webSocketClose" | "webSocketError", + socket: RawWebSocket, + ...args: unknown[] + ): unknown { const instance = this.#actor.classInstance; if (instance.kind !== "running") return undefined; - const handler = (instance.instance as Record)[name]; + const handler: unknown = Reflect.get(instance.instance, name); if (typeof handler !== "function") return undefined; return Reflect.apply(handler, instance.instance, [socket, ...args]); } From 354ed0b116a12a00dad190906da7077089e99b5d Mon Sep 17 00:00:00 2001 From: Alex Nahas Date: Fri, 28 Aug 2026 14:22:31 -0700 Subject: [PATCH 7/7] Prove extension socket rehydration across eviction The hibernation contract was covered by conformance embedders, but the copyable MV3 host still did not preserve an Agents connection when its root container was replaced. Share the in-memory mirror with the extension, rebuild placements from its snapshot, and prove the same AgentClient can receive and send state after eviction. That lifecycle exposed a timer bookkeeping bug: a host may model cancel-by-drop with an unsettled delay promise, leaving a cleared interval permanently counted in waitUntil. Settle the runtime-owned cancellation race and pin it with a regression so quiescence remains a truthful eviction signal. Document the same-Worker boundary explicitly: container replacement rehydrates, while Worker or offscreen destruction removes the raw transport and reconnects. --- conformance/browser/actor.worker.ts | 2 +- conformance/node/host.ts | 2 +- examples/extension/README.md | 55 +++++++++++++----- examples/extension/scripts/e2e.mjs | 57 +++++++++++++++---- examples/extension/src/offscreen/offscreen.ts | 3 + examples/extension/src/protocol.ts | 1 + examples/extension/src/worker/actor.worker.ts | 40 ++++++++++++- .../platform-shims/hibernation-mirror.ts | 4 +- examples/vibe-platform/README.md | 14 +++-- src/io/io-context.test.ts | 18 ++++++ src/io/io-context.ts | 38 ++++++++----- 11 files changed, 183 insertions(+), 51 deletions(-) rename conformance/hibernation-host.ts => examples/platform-shims/hibernation-mirror.ts (90%) diff --git a/conformance/browser/actor.worker.ts b/conformance/browser/actor.worker.ts index ed7b9b6..6034cd4 100644 --- a/conformance/browser/actor.worker.ts +++ b/conformance/browser/actor.worker.ts @@ -77,7 +77,7 @@ import { type SqliteWasmHost, } from "../../backends/sqlite-wasm"; import { Probe } from "../fixtures/probe"; -import { HibernationMirror } from "../hibernation-host"; +import { HibernationMirror } from "../../examples/platform-shims/hibernation-mirror"; import { installWebSocketUpgradeGlobals, upgradeWebSocket, diff --git a/conformance/node/host.ts b/conformance/node/host.ts index 0cf256b..726ada7 100644 --- a/conformance/node/host.ts +++ b/conformance/node/host.ts @@ -64,7 +64,7 @@ import type { ProbeActor, } from "../host"; import { Probe } from "../fixtures/probe"; -import { HibernationMirror } from "../hibernation-host"; +import { HibernationMirror } from "../../examples/platform-shims/hibernation-mirror"; import { installWebSocketUpgradeGlobals, upgradeWebSocket, 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 e8fa7c3..9189c33 100644 --- a/examples/extension/src/worker/actor.worker.ts +++ b/examples/extension/src/worker/actor.worker.ts @@ -56,6 +56,7 @@ import { 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; @@ -543,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; @@ -581,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; } @@ -601,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 // @@ -662,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/conformance/hibernation-host.ts b/examples/platform-shims/hibernation-mirror.ts similarity index 90% rename from conformance/hibernation-host.ts rename to examples/platform-shims/hibernation-mirror.ts index e141b4d..fa56f55 100644 --- a/conformance/hibernation-host.ts +++ b/examples/platform-shims/hibernation-mirror.ts @@ -2,11 +2,11 @@ import type { HibernationHost, RawWebSocket, RehydratedWebSocket, -} from "../src/index"; +} from "@mcp-b/do-runtime"; type MirroredWebSocket = RehydratedWebSocket & { tags: readonly string[] }; -/** In-memory socket state shared by the Node and browser reference embedders. */ +/** 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; 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/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 6fc6475..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()`