diff --git a/AGENTS.md b/AGENTS.md index 9ba0333..4401e37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,14 +39,14 @@ Layout (each directory's justfile module in parentheses): the module reads no ambient configuration. Since the A22 protocol/runtime split, this published package (`@polymorph/websocket`) depends on `@polyengine/protocol` only (`js/polyengine/deno.json`, - caret-pinned `^0.2.2`) — no `@polyengine/runtime` import anywhere in + caret-pinned `^0.2.3`) — no `@polyengine/runtime` import anywhere in it; value recognition is brand-based (`@polyengine/protocol` predicates, not `instanceof`), so its copy of `@polyengine/protocol` is harmless even in a multi-copy graph. The runtime-linked application legs (`conformance/driver-ct/polyengine/`, the WPT parity round trip, the demo) separately exact-pin the `@polyengine/{runtime,translator,wasi, - ct-runner}` lockstep family (`0.5.0`) plus `@polyengine/protocol` - (`0.2.2`) in `conformance/driver-ct/polyengine/deno.json`; the root + ct-runner}` lockstep family (`0.5.1`) plus `@polyengine/protocol` + (`0.2.3`) in `conformance/driver-ct/polyengine/deno.json`; the root justfile's `exam-polyengine` recipe (CI-wired) asserts one resolved runtime-family version, one resolved protocol version, and that `js/polyengine` names no `@polyengine/runtime` specifier. Bump diff --git a/conformance/driver-ct/polyengine/deno.json b/conformance/driver-ct/polyengine/deno.json index 4cc6ee8..e97bb6c 100644 --- a/conformance/driver-ct/polyengine/deno.json +++ b/conformance/driver-ct/polyengine/deno.json @@ -1,12 +1,12 @@ { - "//": "A22 MODULE-IDENTITY POSTURE: @polyengine/wasi@0.5.0 is protocol-only internally (the old module-identity clause tying it to @polyengine/runtime/embedder is gone), so this file's runtime-family pins only need to be exact and mutually consistent with each other and with ../../../js/polyengine/deno.json when that package's caret range resolves the same commit — this repo's driver code (run.ts, examples/polyengine-demo/run.ts) that also loads @polyengine/protocol vocabulary must still resolve to ONE @polyengine/runtime version if it loads the embedder in more than one config, since stateful handles (streams/futures) minted by one copy are refused by another; the published @polymorph/websocket host module, by contrast, now couples only to @polyengine/protocol, whose copies are harmless by construction. Pin story: exact-pinned JSR releases (caret-honest upstream: within a minor line releases stay compatible, breaking bumps the minor; per-commit `-pre.g` prereleases exist between releases — pin exactly); @polyengine/translator ships the translator wasm for that SAME commit; deno.lock carries integrity, --frozen enforced everywhere; the pin gate (justfile: exam-polyengine) asserts one resolved @polyengine/runtime version and one resolved @polyengine/protocol version repo-wide.", + "//": "A22 MODULE-IDENTITY POSTURE: @polyengine/wasi@0.5.1 is protocol-only internally (the old module-identity clause tying it to @polyengine/runtime/embedder is gone), so this file's runtime-family pins only need to be exact and mutually consistent with each other and with ../../../js/polyengine/deno.json when that package's caret range resolves the same commit — this repo's driver code (run.ts, examples/polyengine-demo/run.ts) that also loads @polyengine/protocol vocabulary must still resolve to ONE @polyengine/runtime version if it loads the embedder in more than one config, since stateful handles (streams/futures) minted by one copy are refused by another; the published @polymorph/websocket host module, by contrast, now couples only to @polyengine/protocol, whose copies are harmless by construction. Pin story: exact-pinned JSR releases (caret-honest upstream: within a minor line releases stay compatible, breaking bumps the minor; per-commit `-pre.g` prereleases exist between releases — pin exactly); @polyengine/translator ships the translator wasm for that SAME commit; deno.lock carries integrity, --frozen enforced everywhere; the pin gate (justfile: exam-polyengine) asserts one resolved @polyengine/runtime version and one resolved @polyengine/protocol version repo-wide.", "imports": { - "@polyengine/ct-runner": "jsr:@polyengine/ct-runner@0.5.0", - "@polyengine/runtime/embedder": "jsr:@polyengine/runtime@0.5.0/embedder", - "@polyengine/runtime/shim": "jsr:@polyengine/runtime@0.5.0/shim", - "@polyengine/wasi": "jsr:@polyengine/wasi@0.5.0", - "@polyengine/translator": "jsr:@polyengine/translator@0.5.0", - "@polyengine/protocol": "jsr:@polyengine/protocol@0.2.2", + "@polyengine/ct-runner": "jsr:@polyengine/ct-runner@0.5.1", + "@polyengine/runtime/embedder": "jsr:@polyengine/runtime@0.5.1/embedder", + "@polyengine/runtime/shim": "jsr:@polyengine/runtime@0.5.1/shim", + "@polyengine/wasi": "jsr:@polyengine/wasi@0.5.1", + "@polyengine/translator": "jsr:@polyengine/translator@0.5.1", + "@polyengine/protocol": "jsr:@polyengine/protocol@0.2.3", "@polymorph/test/polyengine-worker-main": "jsr:@polymorph/test@0.2.1/polyengine-worker-main" }, "minimumDependencyAge": { "age": "P1D", "exclude": ["jsr:@polyengine/*", "jsr:@polymorph/*"] }, diff --git a/conformance/driver-ct/polyengine/deno.lock b/conformance/driver-ct/polyengine/deno.lock index 3521ec0..fbabad5 100644 --- a/conformance/driver-ct/polyengine/deno.lock +++ b/conformance/driver-ct/polyengine/deno.lock @@ -1,44 +1,44 @@ { "version": "5", "specifiers": { - "jsr:@polyengine/ct-runner@0.5.0": "0.5.0", - "jsr:@polyengine/protocol@0.2.2": "0.2.2", - "jsr:@polyengine/protocol@~0.2.2": "0.2.2", - "jsr:@polyengine/runtime@0.5": "0.5.0", - "jsr:@polyengine/runtime@0.5.0": "0.5.0", - "jsr:@polyengine/translator@0.5.0": "0.5.0", - "jsr:@polyengine/wasi@0.5.0": "0.5.0", + "jsr:@polyengine/ct-runner@0.5.1": "0.5.1", + "jsr:@polyengine/protocol@0.2.3": "0.2.3", + "jsr:@polyengine/protocol@~0.2.3": "0.2.3", + "jsr:@polyengine/runtime@0.5.1": "0.5.1", + "jsr:@polyengine/runtime@~0.5.1": "0.5.1", + "jsr:@polyengine/translator@0.5.1": "0.5.1", + "jsr:@polyengine/wasi@0.5.1": "0.5.1", "jsr:@polymorph/test@0.2.1": "0.2.1", "npm:@jsr/polymorph__test@0.2.1": "0.2.1", "npm:playwright-core@^1.61.1": "1.62.1" }, "jsr": { - "@polyengine/ct-runner@0.5.0": { - "integrity": "be3219d9ed283a10386fc76590149419888544980155e84f72b988f1b248a2e5", + "@polyengine/ct-runner@0.5.1": { + "integrity": "97746f0ea1750690f30bf3f1e0cb5a1e0d06be9720c983f2084999c6c6e97d89", "dependencies": [ - "jsr:@polyengine/protocol@~0.2.2", - "jsr:@polyengine/runtime@0.5" + "jsr:@polyengine/protocol@~0.2.3", + "jsr:@polyengine/runtime@~0.5.1" ] }, - "@polyengine/protocol@0.2.2": { - "integrity": "ff470d7183924119ae181cc513093318f1587f7ad5c4bc02db7344e90c9ff52b" + "@polyengine/protocol@0.2.3": { + "integrity": "e3f0ac4165eb691d7231164ce28cf75e275c99496aa538e032297febdc52b371" }, - "@polyengine/runtime@0.5.0": { - "integrity": "36ea9b74dd38c0f5fb941695cfa3223190fda54bc18f7328600ece082dbbb794", + "@polyengine/runtime@0.5.1": { + "integrity": "6337c94d623bc2013fdeb324a5206860a916b8897b39cf4bec65b1bf87c287d0", "dependencies": [ - "jsr:@polyengine/protocol@~0.2.2" + "jsr:@polyengine/protocol@~0.2.3" ] }, - "@polyengine/translator@0.5.0": { - "integrity": "2f9770f468bd0c649732e6107d69715dea715c71564fe5b0ba0997b5d70e81c3", + "@polyengine/translator@0.5.1": { + "integrity": "16d7ac88997f89f10145cc109737b749bfebd37c9ea1aefe0a41b44c01b75586", "dependencies": [ - "jsr:@polyengine/runtime@0.5" + "jsr:@polyengine/runtime@~0.5.1" ] }, - "@polyengine/wasi@0.5.0": { - "integrity": "61275e66895084807393bf93876b74be9cc1ca37641d56a07cf8c7b9a78ce091", + "@polyengine/wasi@0.5.1": { + "integrity": "1cb71ff91c24415d85a3b15bbb35462757586fa1478febba605cab4c1b2dbe72", "dependencies": [ - "jsr:@polyengine/protocol@~0.2.2" + "jsr:@polyengine/protocol@~0.2.3" ] }, "@polymorph/test@0.2.1": { @@ -57,11 +57,11 @@ }, "workspace": { "dependencies": [ - "jsr:@polyengine/ct-runner@0.5.0", - "jsr:@polyengine/protocol@0.2.2", - "jsr:@polyengine/runtime@0.5.0", - "jsr:@polyengine/translator@0.5.0", - "jsr:@polyengine/wasi@0.5.0", + "jsr:@polyengine/ct-runner@0.5.1", + "jsr:@polyengine/protocol@0.2.3", + "jsr:@polyengine/runtime@0.5.1", + "jsr:@polyengine/translator@0.5.1", + "jsr:@polyengine/wasi@0.5.1", "jsr:@polymorph/test@0.2.1" ], "packageJson": { diff --git a/js/polyengine/deno.json b/js/polyengine/deno.json index 6f5d088..ee7e27d 100644 --- a/js/polyengine/deno.json +++ b/js/polyengine/deno.json @@ -5,7 +5,7 @@ "exports": "./websocket.ts", "//": "A22 MODULE-IDENTITY POSTURE: this package couples only to @polyengine/protocol (the host-ABI vocabulary package) — no @polyengine/runtime import anywhere in this module, per polyengine's A22 rule that published host modules must not import @polyengine/runtime. websocket.ts recognizes polyengine values by @polyengine/protocol brand predicates, not instanceof, so its copies are harmless by construction: any application that also loads the embedder resolves its own runtime version independently, and this package neither constrains nor is constrained by that choice. Pin story: this published package takes a caret range on @polyengine/protocol (caret-honest upstream: within a minor line releases stay compatible, breaking bumps the minor); deno.lock carries integrity, --frozen enforced everywhere; the pin gate (justfile: exam-polyengine) asserts this file names no @polyengine/runtime specifier.", "imports": { - "@polyengine/protocol": "jsr:@polyengine/protocol@^0.2.2" + "@polyengine/protocol": "jsr:@polyengine/protocol@^0.2.3" }, "minimumDependencyAge": { "age": "P1D", "exclude": ["jsr:@polyengine/*"] }, "compilerOptions": { diff --git a/js/polyengine/deno.lock b/js/polyengine/deno.lock index b430e1b..db462e5 100644 --- a/js/polyengine/deno.lock +++ b/js/polyengine/deno.lock @@ -1,13 +1,13 @@ { "version": "5", "specifiers": { - "jsr:@polyengine/protocol@~0.2.2": "0.2.2", + "jsr:@polyengine/protocol@~0.2.3": "0.2.3", "jsr:@std/assert@1": "1.0.19", "jsr:@std/internal@^1.0.12": "1.0.14" }, "jsr": { - "@polyengine/protocol@0.2.2": { - "integrity": "ff470d7183924119ae181cc513093318f1587f7ad5c4bc02db7344e90c9ff52b" + "@polyengine/protocol@0.2.3": { + "integrity": "e3f0ac4165eb691d7231164ce28cf75e275c99496aa538e032297febdc52b371" }, "@std/assert@1.0.19": { "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", @@ -21,7 +21,7 @@ }, "workspace": { "dependencies": [ - "jsr:@polyengine/protocol@~0.2.2" + "jsr:@polyengine/protocol@~0.2.3" ] } } diff --git a/js/polyengine/tests/echo_server.ts b/js/polyengine/tests/echo_server.ts index be7c0fe..7f71740 100644 --- a/js/polyengine/tests/echo_server.ts +++ b/js/polyengine/tests/echo_server.ts @@ -101,3 +101,67 @@ function handle(req: Request, sockets: Set): Response | Promise; + /** Resolves once the accepted connection's read settles — the client closed. */ + clientGone: Promise; + close(): Promise; +} + +export function startStallStub(): StallStub { + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + const conns: Deno.Conn[] = []; + const readers: Promise[] = []; + + let markAccepted!: () => void; + const accepted = new Promise((r) => (markAccepted = r)); + let markGone!: () => void; + const clientGone = new Promise((r) => (markGone = r)); + + const serving = (async () => { + try { + for await (const conn of listener) { + conns.push(conn); + markAccepted(); + readers.push((async () => { + const buf = new Uint8Array(1024); + try { + // Drain the upgrade request and answer nothing. The read settles + // only when the peer closes or resets. + while (await conn.read(buf) !== null) { /* keep draining */ } + } catch { /* a reset is the client going away too */ } + markGone(); + })()); + } + } catch { /* the listener was closed */ } + })(); + + return { + base: `ws://127.0.0.1:${port}`, + accepted, + clientGone, + async close() { + try { + listener.close(); + } catch { /* already closed */ } + for (const c of conns) { + try { + c.close(); + } catch { /* already closed */ } + } + await serving; + await Promise.all(readers); + }, + }; +} diff --git a/js/polyengine/tests/websocket_test.ts b/js/polyengine/tests/websocket_test.ts index ae49448..33196d8 100644 --- a/js/polyengine/tests/websocket_test.ts +++ b/js/polyengine/tests/websocket_test.ts @@ -9,7 +9,7 @@ // consumer's own conformance suite, executed by conformance/run.ts. import { assert, assertEquals, assertRejects, assertThrows } from "jsr:@std/assert@^1.0.0"; -import { ComponentException } from "@polyengine/protocol"; +import { ComponentException, isAbortable } from "@polyengine/protocol"; import { currentConfig, resetConfig, @@ -20,7 +20,12 @@ import { type WebsocketError, Websocket, } from "../websocket.ts"; -import { burstPayload, startEchoServer, type TestServer } from "./echo_server.ts"; +import { + burstPayload, + startEchoServer, + startStallStub, + type TestServer, +} from "./echo_server.ts"; /** Assert `fn` throws a branded `ComponentException` whose payload kind is `kind`. */ function assertComponentKind(fn: () => unknown, kind: WebsocketError["kind"]): WebsocketError { @@ -332,6 +337,68 @@ Deno.test("connect: the handshake bound fires as connect-failed", async () => { }); }); +/** Whether `p` settles (either way) within `ms`. */ +async function settlesWithin(p: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((r) => { + timer = setTimeout(() => r(false), ms); + }); + try { + return await Promise.race([p.then(() => true, () => true), timeout]); + } finally { + clearTimeout(timer); + } +} + +Deno.test("connect: carries the A24 abortable mark", () => { + assert(isAbortable(Websocket.connect)); +}); + +Deno.test("connect: an abort mid-handshake fails connect-failed and drops the socket", async () => { + resetConfig(); + const stub = startStallStub(); + try { + const controller = new AbortController(); + const connecting = Websocket.connect(stub.base, [], controller.signal); + await stub.accepted; + + const started = performance.now(); + controller.abort(); + const payload = await assertRejectsComponentKind(() => connecting, "connect-failed"); + const elapsed = performance.now() - started; + + // Far inside the module's own 30s connect bound: the abort settled it, + // not the timer. + assert(elapsed < 2_000, `abort did not settle connect promptly (${elapsed}ms)`); + assert("value" in payload && payload.value === "connect aborted"); + assert( + await settlesWithin(stub.clientGone, 2_000), + "the platform socket was not reclaimed by the abort", + ); + } finally { + await stub.close(); + resetConfig(); + } +}); + +Deno.test("connect: a pre-aborted signal fails before any socket is opened", async () => { + resetConfig(); + const stub = startStallStub(); + try { + const controller = new AbortController(); + controller.abort(); + const payload = await assertRejectsComponentKind( + () => Websocket.connect(stub.base, [], controller.signal), + "connect-failed", + ); + assert("value" in payload && payload.value === "connect aborted"); + assertEquals(await settlesWithin(stub.accepted, 250), false); + } finally { + await stub.close(); + resetConfig(); + } +}); + async function drainBytes(stream: ReadableStream): Promise { const chunks: Uint8Array[] = []; let total = 0; diff --git a/js/polyengine/websocket.ts b/js/polyengine/websocket.ts index cef9f90..ef8b018 100644 --- a/js/polyengine/websocket.ts +++ b/js/polyengine/websocket.ts @@ -27,6 +27,7 @@ // as the reference does (websocket.js:57-63). import { + abortable, ComponentException, hasBrand, isComponentException, @@ -337,11 +338,28 @@ export class Websocket { * `connect: static async func(url, protocols) -> result`. * Resolves with a `Websocket` once the handshake completes; throws * `ComponentException` on failure. websocket.js:203. + * + * Marked abortable: an embedder appends an `AbortSignal` after the + * WIT-declared parameters, and aborts it only when a guest cancellation + * discards this call. An abort reclaims the platform socket at once — a + * pending handshake fails `connect-failed`, and a handshake that already + * completed has its socket closed instead of being left to the module's own + * connect bound. Direct callers may omit the signal. */ - static async connect(url: string, protocols: string[]): Promise { + @abortable + static async connect( + url: string, + protocols: string[], + signal?: AbortSignal, + ): Promise { validateUrl(url); validateProtocols(protocols); + if (signal?.aborted) { + // Nothing to reclaim yet: refuse before the platform socket exists. + throw componentError({ kind: "connect-failed", value: "connect aborted" }); + } + let ws: WebSocket; try { ws = protocols.length ? new WebSocket(url, protocols) : new WebSocket(url); @@ -362,6 +380,7 @@ export class Websocket { ws.removeEventListener("open", onOpen); ws.removeEventListener("close", onClose); ws.removeEventListener("error", onError); + signal?.removeEventListener("abort", onAbort); fn(value); }; const onOpen = () => settle(resolve as (v?: unknown) => void); @@ -381,9 +400,21 @@ export class Websocket { // An `error` event is always followed by `close`; wait for it so // the reason (if any) rides along. }; + const onAbort = () => { + settle( + reject, + componentError({ kind: "connect-failed", value: "connect aborted" }), + ); + try { + ws.close(); + } catch { + // Nothing to reclaim. + } + }; ws.addEventListener("open", onOpen, { once: true }); ws.addEventListener("close", onClose, { once: true }); ws.addEventListener("error", onError, { once: true }); + signal?.addEventListener("abort", onAbort, { once: true }); timer = setTimeout(() => { settle( reject, @@ -426,6 +457,20 @@ export class Websocket { }); } + if (signal) { + // The abort is discard-only: it fires just when a guest cancellation + // discarded this call, so this connection can never reach the caller. + // Closing it unconditionally is therefore disposal of an undeliverable + // resource, never teardown of a connection someone still holds. + const disposeOnAbort = () => { + try { + ws.close(); + } catch { /* already closing */ } + }; + if (signal.aborted) disposeOnAbort(); + else signal.addEventListener("abort", disposeOnAbort, { once: true }); + } + return new Websocket(ws); }