Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions conformance/driver-ct/deltic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 4 additions & 5 deletions conformance/driver-ct/deltic/browser/worker-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion conformance/driver-ct/deltic/deno.json
Original file line number Diff line number Diff line change
@@ -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 `<next>-pre.g<hash>` 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 `<next>-pre.g<hash>` 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",
Expand Down
3 changes: 2 additions & 1 deletion conformance/driver-ct/deltic/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions examples/deltic-demo/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` 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";
Expand Down Expand Up @@ -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;
}
Expand Down
7 changes: 4 additions & 3 deletions js/componentize/wpt/parity/deltic-carrier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
8 changes: 6 additions & 2 deletions js/deltic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion js/deltic/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
64 changes: 64 additions & 0 deletions js/deltic/tests/websocket_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ComponentException } from "@deltic/runtime/embedder";
import {
currentConfig,
resetConfig,
type SendViaStreamError,
setConnectTimeoutMs,
setMaxInboundBufferBytes,
type StreamMessage,
Expand Down Expand Up @@ -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<Websocket["sendViaStream"]>[0]),
ComponentException,
) as ComponentException<SendViaStreamError>;
// 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<u8>`: 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<Uint8Array> {
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<Websocket["sendViaStream"]>[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.
Expand Down
26 changes: 17 additions & 9 deletions js/deltic/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@

import {
ComponentException,
Stream,
hasBrand,
isComponentException,
STREAM,
type Stream,
type StreamSource,
} from "@deltic/runtime/embedder";

Expand Down Expand Up @@ -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<SendViaStreamError>(
Expand Down Expand Up @@ -894,12 +899,15 @@ async function collectByteStream(
} finally {
reader.releaseLock();
}
} else if (stream instanceof Stream) {
// The conventions' `Stream<u8>`: `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<u8>`, 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<number>;
for (;;) {
const chunk = await stream.read(READ_BATCH);
const chunk = await handle.read(READ_BATCH);
if ((chunk as Uint8Array).length === 0) break;
push(chunk);
}
Expand Down
Loading