From c7932cea646f851e4b10f7215f59bbcae70c0e26 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Mon, 17 Aug 2026 17:28:05 -0400 Subject: [PATCH] js/deltic: recognize deltic values by protocol brands, not class identity Migrate the three class-identity recognition sites to the pinned deltic release's @deltic/protocol predicates, already re-exported from @deltic/runtime/embedder at the current pin: - websocket.ts sendViaStream error wrap: instanceof WitError -> isWitError, so a WitError minted by another runtime copy passes its structured payload through instead of being flattened to { tag: "other" }. - websocket.ts collectByteStream: instanceof Stream -> hasBrand(STREAM), so a foreign-copy stream handle dispatches to the batched-read branch (previously it fell through to the async-iterable fallback). - examples/deltic-demo/run.ts error display: instanceof WitError -> isWitError. WitError stays imported for construction; recognition alone migrates. Two unit tests hand-roll branded values from the Symbol.for registry keys and fail against the instanceof code, proving recognition no longer depends on module identity. The MODULE-IDENTITY comments narrow to the pin gate's remaining rationale: one runtime/translator plan-format pairing per graph, refusal of cross-copy stateful handles, and the single-version review surface. Fixes #48 --- AGENTS.md | 4 +- conformance/driver-ct/deltic/README.md | 5 +- .../driver-ct/deltic/browser/worker-entry.ts | 9 ++- conformance/driver-ct/deltic/deno.json | 2 +- conformance/driver-ct/deltic/run.ts | 3 +- examples/deltic-demo/run.ts | 10 +-- js/componentize/wpt/parity/deltic-carrier.ts | 7 ++- js/deltic/README.md | 8 ++- js/deltic/deno.json | 2 +- js/deltic/tests/websocket_test.ts | 63 +++++++++++++++++++ js/deltic/websocket.ts | 26 +++++--- 11 files changed, 109 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aed500f..a17ec04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,9 @@ Layout (each directory's justfile module in parentheses): upstream commit) which must agree on the `@deltic/runtime` version (deltic's `wasi-shims` imports `@deltic/runtime/embedder` by bare specifier internally, so a drift would load the embedder module twice - and break `instanceof WitError` across the boundary); the root + and split the runtime/translator plan-format pairing; value recognition + itself is brand-based — deltic's `@deltic/protocol` predicates, not + `instanceof` — and survives a multi-copy graph); the root justfile's `exam-deltic` recipe (CI-wired) asserts one version repo-wide. Bump procedure: `conformance/driver-ct/deltic/README.md`. - `js/componentize/` (`just wpt::…`) — `websocket.js`, the WHATWG-API diff --git a/conformance/driver-ct/deltic/README.md b/conformance/driver-ct/deltic/README.md index fe7ffe2..a591971 100644 --- a/conformance/driver-ct/deltic/README.md +++ b/conformance/driver-ct/deltic/README.md @@ -36,8 +36,9 @@ prereleases"). It is pinned in **two** places, both required to agree: SAME `@deltic/runtime` version (the module-identity constraint: deltic's `wasi-shims` imports `@deltic/runtime/embedder` by bare specifier internally, so every config resolving it must agree, or the embedder - module loads twice and `instanceof WitError` stops holding across the - boundary). + module loads twice — the host module's brand-based value recognition + survives that, but a second copy splits the runtime/translator + plan-format pairing). `@deltic/translator` ships the translator wasm asset **for the same commit** as `@deltic/runtime`, so the plan-format coupling is diff --git a/conformance/driver-ct/deltic/browser/worker-entry.ts b/conformance/driver-ct/deltic/browser/worker-entry.ts index 9c3573e..21a4ec0 100644 --- a/conformance/driver-ct/deltic/browser/worker-entry.ts +++ b/conformance/driver-ct/deltic/browser/worker-entry.ts @@ -2,11 +2,10 @@ // surface (the local browser-bundle-entry.ts, re-exporting the pinned // JSR packages' public surface), the upstream worker message loop, and // this repo's deltic host module, resolved through ONE import map so the -// emitted bundle carries exactly one embedder module instance — which is -// what keeps `instanceof WitError` true when the host module throws -// across the component boundary (workers resolve no import maps, so -// bundling is the only sound shape; see @polymorph/component-test-js's -// runner-deltic README). +// emitted bundle carries exactly one embedder module instance — one +// runtime/translator plan-format pairing, one copy on the runtime's +// copy census (workers resolve no import maps, so bundling is the only +// sound shape; see @polymorph/component-test-js's runner-deltic README). // // Built by `just conformance-ct::run-deltic-browser` with // `deno bundle --platform browser` into target/deltic-browser/, and diff --git a/conformance/driver-ct/deltic/deno.json b/conformance/driver-ct/deltic/deno.json index 4cfc6be..a8db3e3 100644 --- a/conformance/driver-ct/deltic/deno.json +++ b/conformance/driver-ct/deltic/deno.json @@ -1,5 +1,5 @@ { - "//": "MODULE-IDENTITY CONSTRAINT: deltic's wasi-shims module imports @deltic/runtime/embedder by bare specifier internally. The @deltic/runtime/embedder entry here must map to the IDENTICAL exact-pinned JSR version as ../../../js/deltic/deno.json's entry, or the embedder module loads twice and `instanceof WitError` stops holding across the module boundary. Pin story: 0.1.0-pre.g50778a1 names one upstream deltic commit; @deltic/translator ships the translator wasm for that SAME commit; deno.lock carries integrity, --frozen enforced everywhere; one version repo-wide is asserted by the pin gate (justfile: exam-deltic).", + "//": "MODULE-IDENTITY CONSTRAINT: deltic's wasi-shims module imports @deltic/runtime/embedder by bare specifier internally. The @deltic/runtime/embedder entry here must map to the IDENTICAL exact-pinned JSR version as ../../../js/deltic/deno.json's entry, or the embedder module loads twice: value recognition survives that (websocket.ts recognizes deltic values by @deltic/protocol brand predicates, not instanceof), but a second copy splits the runtime/translator plan-format pairing, and stateful handles (streams/futures) minted by one copy are refused by the other. Pin story: 0.1.0-pre.g50778a1 names one upstream deltic commit; @deltic/translator ships the translator wasm for that SAME commit; deno.lock carries integrity, --frozen enforced everywhere; one version repo-wide is asserted by the pin gate (justfile: exam-deltic).", "imports": { "@deltic/ct-runner": "jsr:@deltic/ct-runner@0.1.0-pre.g50778a1", "@deltic/runtime/embedder": "jsr:@deltic/runtime@0.1.0-pre.g50778a1/embedder", diff --git a/conformance/driver-ct/deltic/run.ts b/conformance/driver-ct/deltic/run.ts index c4e7009..00f5c3d 100644 --- a/conformance/driver-ct/deltic/run.ts +++ b/conformance/driver-ct/deltic/run.ts @@ -31,7 +31,8 @@ // `@deltic/runtime/embedder` by bare specifier internally; this leg's // `deno.json` AND `js/deltic/deno.json` must map that specifier to the // IDENTICAL exact-pinned JSR version, or the embedder module loads twice -// and `instanceof WitError` stops holding across the module boundary. +// and the graph carries two runtime/translator plan-format pairings (the +// host module's value recognition is brand-based and survives that). // // The translator comes from the packaged `@deltic/translator` JSR // prerelease (defaultTranslator()) by default — no fetch step, no diff --git a/examples/deltic-demo/run.ts b/examples/deltic-demo/run.ts index 7519c38..c448991 100644 --- a/examples/deltic-demo/run.ts +++ b/examples/deltic-demo/run.ts @@ -16,13 +16,13 @@ // conformance/driver-ct/deltic/deno.json. The translator comes from the // packaged `@deltic/translator` JSR prerelease by default (no fetch // step); `--translator ` remains as an optional override for a -// locally-built translator shim. The same import map is what keeps -// `instanceof WitError` holding across the host-module boundary (see -// that deno.json's MODULE-IDENTITY note). +// locally-built translator shim. The same import map keeps one embedder +// instance — one runtime/translator plan-format pairing — in the graph +// (see that deno.json's MODULE-IDENTITY note). import { Translator } from "@deltic/runtime/shim"; import type { ComponentArtifacts } from "@deltic/runtime/embedder"; -import { instantiate, WitError } from "@deltic/runtime/embedder"; +import { instantiate, isWitError } from "@deltic/runtime/embedder"; import { defaultTranslator } from "@deltic/translator"; import { wasiShims } from "@deltic/wasi-shims"; import { websocketImports } from "../../js/deltic/websocket.ts"; @@ -126,7 +126,7 @@ async function main() { const received = await demo.run(`${echod.base}/echo`, cli.count); console.log(`round-tripped ${received}/${cli.count} messages`); } catch (err) { - const detail = err instanceof WitError ? err.payload : err; + const detail = isWitError(err) ? err.payload : err; console.error(`demo failed: ${detail}`); Deno.exitCode = 1; } diff --git a/js/componentize/wpt/parity/deltic-carrier.ts b/js/componentize/wpt/parity/deltic-carrier.ts index c330915..f38bed9 100644 --- a/js/componentize/wpt/parity/deltic-carrier.ts +++ b/js/componentize/wpt/parity/deltic-carrier.ts @@ -23,9 +23,10 @@ // the bundle recipe runs `deno bundle` from that directory) and the // translator asset is extracted from the packaged `@deltic/translator` // JSR package's lock-pinned module cache (no fetch step). Bundling from -// there is also what keeps `@deltic/runtime/embedder` a single module -// instance across this bundle and js/deltic/websocket.ts, so -// `instanceof WitError` holds. +// there also keeps `@deltic/runtime/embedder` a single module instance +// across this bundle and js/deltic/websocket.ts — one runtime/translator +// plan-format pairing (value recognition is brand-based and would +// survive a multi-copy bundle; the pairing would not). import { Translator } from "@deltic/runtime/shim"; import { instantiate } from "@deltic/runtime/embedder"; diff --git a/js/deltic/README.md b/js/deltic/README.md index 4524197..c431611 100644 --- a/js/deltic/README.md +++ b/js/deltic/README.md @@ -35,8 +35,12 @@ identically on Deno. `deno.json`'s `@deltic/runtime/embedder` import maps to the exact same pinned URL as `conformance/driver-ct/deltic/deno.json`. deltic's `wasi-shims` module imports that specifier by bare name internally; if the -two configs ever disagreed, the embedder module would load twice and -`instanceof WitError` would stop holding across the boundary. Keep both +two configs ever disagreed, the embedder module would load twice. This +module recognizes deltic values by `@deltic/protocol` brand predicates +(`isWitError`, the `STREAM` brand), so recognition holds even in a +multi-copy graph — but a duplicated embedder still splits the +runtime/translator plan-format pairing, and stateful handles +(streams/futures) minted by one copy are refused by the other. Keep both import maps byte-identical for that one entry. ## Unit tests diff --git a/js/deltic/deno.json b/js/deltic/deno.json index 136793a..69819df 100644 --- a/js/deltic/deno.json +++ b/js/deltic/deno.json @@ -2,7 +2,7 @@ "name": "@polymorph/websocket-deltic", "version": "0.0.0", "exports": "./websocket.ts", - "//": "MODULE-IDENTITY CONSTRAINT: deltic's wasi-shims module imports @deltic/runtime/embedder by bare specifier internally. Every config in this repo (this file AND conformance/driver-ct/deltic/deno.json) must map @deltic/runtime to the IDENTICAL exact-pinned JSR version, or the embedder module loads twice and `instanceof WitError` stops holding across the module boundary. Pin story: 0.1.0-pre.g50778a1 names one upstream deltic commit; @deltic/translator ships the translator wasm for that SAME commit; deno.lock carries integrity, --frozen enforced everywhere; one version repo-wide is asserted by the pin gate (justfile: exam-deltic).", + "//": "MODULE-IDENTITY CONSTRAINT: deltic's wasi-shims module imports @deltic/runtime/embedder by bare specifier internally. Every config in this repo (this file AND conformance/driver-ct/deltic/deno.json) must map @deltic/runtime to the IDENTICAL exact-pinned JSR version, or the embedder module loads twice: value recognition survives that (websocket.ts recognizes deltic values by @deltic/protocol brand predicates, not instanceof), but a second copy splits the runtime/translator plan-format pairing, and stateful handles (streams/futures) minted by one copy are refused by the other. Pin story: 0.1.0-pre.g50778a1 names one upstream deltic commit; @deltic/translator ships the translator wasm for that SAME commit; deno.lock carries integrity, --frozen enforced everywhere; one version repo-wide is asserted by the pin gate (justfile: exam-deltic).", "imports": { "@deltic/runtime/embedder": "jsr:@deltic/runtime@0.1.0-pre.g50778a1/embedder" }, diff --git a/js/deltic/tests/websocket_test.ts b/js/deltic/tests/websocket_test.ts index 55f4ae4..3f60928 100644 --- a/js/deltic/tests/websocket_test.ts +++ b/js/deltic/tests/websocket_test.ts @@ -13,6 +13,7 @@ import { WitError } from "@deltic/runtime/embedder"; import { currentConfig, resetConfig, + type SendViaStreamError, setConnectTimeoutMs, setMaxInboundBufferBytes, type StreamMessage, @@ -212,6 +213,68 @@ Deno.test("receive-via-stream: single-use; pending receive is rejected", async ( }); }); +// The two tests below hand-roll deltic values from nothing but the +// @deltic/protocol registry brands (`Symbol.for` keys pinned by upstream's +// own tests): per the protocol contract, an object carrying the brand is a +// legal value from ANY runtime copy, so these prove the module's +// recognition sites work without class identity — the multi-copy exposure +// #48 closes. The literal `/1` keys are deliberate: a brand-generation +// bump upstream must fail here and force the recognition sites to be +// revisited. + +Deno.test("send-via-stream: a foreign-copy WitError's payload passes through the error wrap", async () => { + await withServer(async (s) => { + const ws = await Websocket.connect(`${s.base}/echo`, []); + const foreign = Object.assign(new Error("minted elsewhere"), { + [Symbol.for("deltic.witError/1")]: true, + payload: { tag: "invalid-argument", val: "minted by another copy" }, + }); + const producer = (async function* () { + throw foreign; + })(); + const e = await assertRejects( + () => ws.sendViaStream(producer as unknown as Parameters[0]), + WitError, + ) as WitError; + // The structured payload must pass through, not be flattened to + // `{ tag: "other", val: String(error) }` as an unrecognized throw is. + assertEquals(e.payload.error, { + tag: "invalid-argument", + val: "minted by another copy", + } as WebsocketError); + assertEquals(e.payload.sent, 0n); + ws.close(1000, ""); + await ws.waitClosed(); + }); +}); + +Deno.test("send-via-stream: a hand-rolled branded byte stream takes the batched-read path", async () => { + await withServer(async (s) => { + const ws = await Websocket.connect(`${s.base}/echo`, []); + const payload = new Uint8Array([104, 101, 121]); + // A minimal branded `Stream`: the brand plus the conventions' + // `read(max)` (empty chunk = end-of-stream). Deliberately neither a + // `ReadableStream` nor async-iterable, so only brand recognition can + // route it to the batched-read branch. + let reads = 0; + const data = { + [Symbol.for("deltic.stream/1")]: true, + read(_max: number): Promise { + reads += 1; + return Promise.resolve(reads === 1 ? payload : new Uint8Array(0)); + }, + }; + const producer = (async function* () { + yield { kind: "binary", length: payload.length, data }; + })(); + await ws.sendViaStream(producer as unknown as Parameters[0]); + const echoed = await ws.receive(); + assertEquals(echoed, { tag: "binary", val: payload }); + ws.close(1000, ""); + await ws.waitClosed(); + }); +}); + Deno.test("flow control: overflow closes, backlog stays receivable, then overflow error", async () => { await withServer(async (s) => { // A shrunk bound so a modest burst overflows it deterministically. diff --git a/js/deltic/websocket.ts b/js/deltic/websocket.ts index 66c60e8..26d3d68 100644 --- a/js/deltic/websocket.ts +++ b/js/deltic/websocket.ts @@ -27,7 +27,10 @@ // as the reference does (websocket.js:57-63). import { - Stream, + hasBrand, + isWitError, + STREAM, + type Stream, type StreamSource, WitError, } from "@deltic/runtime/embedder"; @@ -521,9 +524,11 @@ export class Websocket { sent += 1n; } } catch (error) { - // A WIT error variant passes through; anything else is a host-side - // failure and must not masquerade as a normal close. websocket.js:370-378. - const payload: WebsocketError = error instanceof WitError + // A WIT error variant passes through — recognized by its + // process-global brand, so a `WitError` minted by any runtime copy + // counts; anything else is a host-side failure and must not + // masquerade as a normal close. websocket.js:370-378. + const payload: WebsocketError = isWitError(error) ? error.payload as WebsocketError : { tag: "other", val: String(error) }; throw new WitError( @@ -894,12 +899,15 @@ async function collectByteStream( } finally { reader.releaseLock(); } - } else if (stream instanceof Stream) { - // The conventions' `Stream`: `read(max)` yields a `Uint8Array`, and - // an EMPTY chunk means end-of-stream (contracts/embedder-api.md - // §"Streams and futures"). Read in batches rather than per element. + } else if (hasBrand(stream, STREAM)) { + // The conventions' `Stream`, recognized by its process-global brand + // (a handle minted by any runtime copy dispatches here): `read(max)` + // yields a `Uint8Array`, and an EMPTY chunk means end-of-stream + // (contracts/embedder-api.md §"Streams and futures"). Read in batches + // rather than per element. + const handle = stream as Stream; for (;;) { - const chunk = await stream.read(READ_BATCH); + const chunk = await handle.read(READ_BATCH); if ((chunk as Uint8Array).length === 0) break; push(chunk); }