diff --git a/AGENTS.md b/AGENTS.md index a8abf26..f9efc90 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 ComponentException` 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 2859008..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 ComponentException` 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 434cea2..ea84a82 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 ComponentException` true when the host module throws -// across the component boundary (workers resolve no import maps, so -// bundling is the only sound shape; see @jsr/polymorph__test'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 @jsr/polymorph__test'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 238eaf5..231551e 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 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 ComponentException` stops holding across the module boundary. 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); @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 module imports @deltic/runtime/embedder by bare specifier internally. The @deltic/runtime/embedder entry here must resolve to the IDENTICAL @deltic/runtime version as ../../../js/deltic/deno.json's caret range (in both deno.locks), 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: 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); @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", "@deltic/runtime/embedder": "jsr:@deltic/runtime@0.1.0/embedder", diff --git a/conformance/driver-ct/deltic/run.ts b/conformance/driver-ct/deltic/run.ts index d58626b..4a882e4 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 ComponentException` 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 857343c..7b3e060 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 ComponentException` 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 { ComponentException, instantiate } from "@deltic/runtime/embedder"; +import { instantiate, isComponentException } from "@deltic/runtime/embedder"; import { defaultTranslator } from "@deltic/translator"; import { wasi } from "@deltic/wasi"; 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 ComponentException ? err.payload : err; + const detail = isComponentException(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 a47ef78..000de1f 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 ComponentException` 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 dc3fc39..0e41064 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 ComponentException` 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 +(`isComponentException`, 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 096d0c2..90af8d9 100644 --- a/js/deltic/deno.json +++ b/js/deltic/deno.json @@ -3,7 +3,7 @@ "version": "0.1.0", "license": "Apache-2.0", "exports": "./websocket.ts", - "//": "MODULE-IDENTITY CONSTRAINT: deltic's wasi module imports @deltic/runtime/embedder by bare specifier internally. This file's @deltic/runtime range and conformance/driver-ct/deltic/deno.json's exact pin must resolve to the IDENTICAL @deltic/runtime version in both deno.locks, or the embedder module loads twice and `instanceof ComponentException` stops holding across the module boundary. Pin story: this published package takes a caret range (caret-honest upstream: within a minor line releases stay compatible, breaking bumps the minor); the repo-internal conformance harness exact-pins instead so its lock is the single source of truth for the resolved version; deno.lock carries integrity, --frozen enforced everywhere; one RESOLVED version repo-wide (across both locks) is asserted by the pin gate (justfile: exam-deltic).", + "//": "MODULE-IDENTITY CONSTRAINT: deltic's wasi module imports @deltic/runtime/embedder by bare specifier internally. This file's @deltic/runtime range and conformance/driver-ct/deltic/deno.json's exact pin must resolve to the IDENTICAL @deltic/runtime version in both deno.locks, 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: this published package takes a caret range (caret-honest upstream: within a minor line releases stay compatible, breaking bumps the minor); the repo-internal conformance harness exact-pins instead so its lock is the single source of truth for the resolved version; deno.lock carries integrity, --frozen enforced everywhere; one RESOLVED version repo-wide (across both locks) is asserted by the pin gate (justfile: exam-deltic).", "imports": { "@deltic/runtime/embedder": "jsr:@deltic/runtime@^0.1.0/embedder" }, diff --git a/js/deltic/tests/websocket_test.ts b/js/deltic/tests/websocket_test.ts index 4d36b17..0f4d686 100644 --- a/js/deltic/tests/websocket_test.ts +++ b/js/deltic/tests/websocket_test.ts @@ -13,6 +13,7 @@ import { ComponentException } from "@deltic/runtime/embedder"; import { currentConfig, resetConfig, + type SendViaStreamError, setConnectTimeoutMs, setMaxInboundBufferBytes, type StreamMessage, @@ -212,6 +213,69 @@ 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 keys are deliberate: a brand-generation bump +// upstream must fail here and force the recognition sites to be +// revisited. (Key spellings are stable across upstream renames: the +// `ComponentException` brand is still `deltic.witError/1`.) + +Deno.test("send-via-stream: a foreign-copy ComponentException'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: { kind: "invalid-argument", value: "minted by another copy" }, + }); + const producer = (async function* () { + throw foreign; + })(); + const e = await assertRejects( + () => ws.sendViaStream(producer as unknown as Parameters[0]), + ComponentException, + ) as ComponentException; + // The structured payload must pass through, not be flattened to + // `{ kind: "other", value: String(error) }` as an unrecognized throw is. + assertEquals(e.payload.error, { + kind: "invalid-argument", + value: "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, { kind: "binary", value: 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 7b4250e..e68cf30 100644 --- a/js/deltic/websocket.ts +++ b/js/deltic/websocket.ts @@ -28,7 +28,10 @@ import { ComponentException, - Stream, + hasBrand, + isComponentException, + STREAM, + type Stream, type StreamSource, } 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 ComponentException + // A WIT error variant passes through — recognized by its + // process-global brand, so a `ComponentException` 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 = isComponentException(error) ? error.payload as WebsocketError : { kind: "other", value: String(error) }; throw new ComponentException( @@ -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); }