diff --git a/AGENTS.md b/AGENTS.md index 0e770bd..235dd2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,6 +153,21 @@ Standing rules: hit); and `cut` mode on `release=true`, which turns the window's labels into the minor-bump requirement and renders the release notes. +- **The host ABI is versioned by `@polyengine/protocol`, gated by goldens** + (contracts/embedder-api.md amendment A22). The conventions suite + (`runtime/tests/conventions/`, rides `just test-runtime`; focused run: + `just test-conventions`) pins the host-facing lift/lower behavior as + committed transcripts under `runtime/tests/conventions/golden/`. + Modifying or deleting a golden asserts a host-ABI behavior change and + requires `breaking/protocol` in the same PR (the reviewed + behavior-neutral escape is the `conventions-fix` label); adding goldens + is free. version-guard enforces this in `pr` mode (advisory, live + labels) and authoritatively in `cut` mode (window-wide diff of the + goldens dir; M/D requires protocol on a later minor line than the last + cut, or a `conventions-fix` window PR). Host modules import + `@polyengine/protocol` at most — the runtime's exported surface is + application-only — so lockstep releases that leave the goldens + byte-identical cannot touch a host-provider package. - **Cutting a release.** (1) Sanity pass, the step no machine can do: enumerate the window — `gh pr list --search "base:main merged:>="` (or `gh api repos/$R/compare/v...main --jq diff --git a/bench/boundary/driver-polyengine.mjs b/bench/boundary/driver-polyengine.mjs index 5df7e8a..d7c3438 100644 --- a/bench/boundary/driver-polyengine.mjs +++ b/bench/boundary/driver-polyengine.mjs @@ -60,7 +60,7 @@ if (shape.startsWith("stream-")) { const payload = new Uint8Array(totalBytes).fill(0xa5); async function runStreamSink() { - const { stream, writer } = polyengine.Stream.create(); + const { stream, writer } = polyengine.createStream(); const call = inst.exports.streamSink(stream); const feed = (async () => { for (let off = 0; off < totalBytes; off += chunkSize) { @@ -85,7 +85,7 @@ if (shape.startsWith("stream-")) { } async function runStreamPass() { - const { stream, writer } = polyengine.Stream.create(); + const { stream, writer } = polyengine.createStream(); const outP = inst.exports.streamPass(stream); const feed = (async () => { for (let off = 0; off < totalBytes; off += chunkSize) { diff --git a/contracts/embedder-api.md b/contracts/embedder-api.md index 6f353fc..acdb744 100644 --- a/contracts/embedder-api.md +++ b/contracts/embedder-api.md @@ -531,6 +531,12 @@ class ErrorContext { readonly message: string } // lift-only constructor-wise ( class DroppedError extends Error { … } // awaiting a dropped future rejects with this ``` +Since amendment A22 these interfaces (plus `StreamWriter` and the aux +types) are exported, executable, from `@polyengine/protocol`, with brand +predicates `isStream`/`isStreamWriter`/`isFuture`/`isErrorContext`; the +runtime's concrete classes implement them and are not exported (§"The +host-ABI surface and its version"). + - **Future results are eager handles** (C2 amendment): an export whose WIT result is `future` returns `Future` **directly**, not `Promise>` — JS promise resolution unconditionally adopts @@ -557,7 +563,11 @@ class DroppedError extends Error { … } // awaiting a dropped future rejects - **Lowering accepts the natural JS producers**: where the guest expects a `stream`, the host may pass a `ReadableStream`, an `AsyncIterable`, an array (finite), or a `Stream` handle; for `future`, a - `Promise` or `Future`. Bindgen adapts and **owns the pumping**: + `Promise` or `Future`. A `Future` **handle** is lowerable once + its host end has materialized (its producing call completed — the A16 + deferred window); lowering a still-deferred handle is refused loudly, + never queued (A22 suite evidence — a thenable or `Promise` has no such + window and is always accepted). Bindgen adapts and **owns the pumping**: the driving arms auto-close on end/`DROPPED` (eliminating the deadlock-masking activity-lifetime footgun — R-fix review note 2), and cross-store reuse is a runtime-asserted error, not silent misbehavior @@ -628,7 +638,7 @@ class DroppedError extends Error { … } // awaiting a dropped future rejects transfers it, exactly as between two guests); - host↔host rendezvous is legal for **every** element type — the same-instance restriction applies to component instances only; - - a `Stream.create()` writer keeps feeding the same stream across hops + - a `createStream()` writer keeps feeding the same stream across hops (the writer half addresses the shared end, not a particular handle). - **Deadlock-verdict suppression tracks host retention** (amendment A15, 2026-08-21 — issue #162). While the host retains a way to act on a @@ -674,8 +684,8 @@ class DroppedError extends Error { … } // awaiting a dropped future rejects fresh local context — see §"Realm boundaries and structured-clone-safe forms".) - Writer-side host ends (`hostStream()`-era API) remain the low-level seam - underneath; the conventions layer exposes them as - `Stream.create(): { stream: Stream, writer: StreamWriter }` + underneath; the conventions layer exposes them as the application-surface + factory (A22) `createStream(): { stream: Stream, writer: StreamWriter }` with `write`/`writeAll`/`writeDirect`/`cancelWrite`/`close`. - **Component faults are loud on stream/future operations** (amendment A7). When the component instance holding the peer end traps, its live @@ -789,7 +799,7 @@ class DroppedError extends Error { … } // awaiting a dropped future rejects `cancelRead` retract a parked session (A8's indistinguishability caveats unchanged); the A15 transfer guard applies to `readDirect` as to `read`; a parked session is retention, so the deadlock-verdict arm - stays live. `writeDirect` on an unbound `Stream.create()` writer parks + stays live. `writeDirect` on an unbound `createStream()` writer parks until the lowering site binds the element type, then requires u8; `readDirect` on an unbound or non-u8 stream throws, as `read`'s refusals do. @@ -883,7 +893,9 @@ embedder code keeps working with no import changes. Host-module packages SHOULD import `@polyengine/protocol` at most (never runtime values); with hand-rolled brands (below) even that import is optional. Copies of the protocol package are harmless by construction — identity never rests on -the package, only on the registry symbols. +the package, only on the registry symbols. (**Superseded by A22**: the +runtime re-exports are removed and the SHOULD is a MUST for published +host modules — see §"The host-ABI surface and its version".) **Brands.** Every brand is a `Symbol.for` registry symbol, so N copies of the runtime (or of the protocol package) agree on every brand without @@ -901,6 +913,7 @@ equivalent of a semver major: | `polyengine.streamProducer/1` | `StreamProducerError.prototype` | producer-side failures | | `polyengine.suspending/1` | the marked function / class prototype (A1/A2) | suspendable sync imports | | `polyengine.stream/1` | `Stream.prototype` | embedder stream handles | +| `polyengine.streamWriter/1` | `StreamWriter.prototype` (A22) | embedder stream writer handles | | `polyengine.future/1` | `Future.prototype` | embedder future handles | | `polyengine.errorContext/1` | `ErrorContext.prototype` | error-contexts (message-valued at lowering since A20) | | `polyengine.resourceState/1` | guest-resource wrappers (key for internal state; the state shape stays runtime-internal) | resource wrappers | @@ -1177,6 +1190,103 @@ A20 ships in `@polyengine/protocol` 0.2.0 — the same pending release as A19's key rename (new exports; the runtime re-exports them unchanged, per A9). +## The host-ABI surface and its version (amendment A22) + +Consumer evidence (2026-08-22, the polymorph-webcrypto decoupling +question): a published host module consumed exactly three engine exports — +`ComponentException`, `Stream` (type-only), `suspending` — yet its import +map named `jsr:@polyengine/runtime@^0.4.0`, so every lockstep minor +(plan-format bumps, translator breaks: nothing a host module can observe) +invalidated its range and forced a republish. The in-repo `wasi` package +has the same three-symbol footprint, plus four `instanceof Stream` sites — +the class-identity anti-pattern A9 exists to kill. A9 removed class +identity from the contract; A22 removes the remaining *type and specifier* +coupling, and gives the behavioral conventions an executable definition +whose version is `@polyengine/protocol`'s. + +**Protocol carries the whole host-boundary vocabulary.** In addition to +the A9 set, `@polyengine/protocol` exports, as executable TypeScript: + +- the handle interfaces of §"Streams and futures" — `Stream`, + `StreamWriter`, `Future`, `ErrorContext` — as **structural + interfaces** (`Chunk`, `DirectSource`, `DirectDestination`, and the + lowering-source unions `StreamSource`/`FutureSource` ride along); +- brand predicates for the stateful values: `isStream`, `isStreamWriter`, + `isFuture`, `isErrorContext`. Handle recognition is by brand, as + everywhere since A9 — `instanceof` against a concrete class is not + contract behavior in any package. + +The brand table gains `polyengine.streamWriter/1` (carried by the writer +prototype) — an additive generation-1 vocabulary change; writers carried +no brand before because nothing needed to recognize one, and +`isStreamWriter` now does. The runtime's concrete classes declare +`implements` against the protocol interfaces: conformance is a +compile-time assertion pinned by `just test-runtime`, plus the +conventions suite below. + +**The runtime's exported surface is application-only.** +`@polyengine/runtime/embedder` stops exporting everything a host module +could want: the A9 courtesy re-exports (error classes, predicates, +brands, `suspending`, realm crossing, the copy registry) and the concrete +handle classes (`Stream`, `StreamWriter`, `Future`, `ErrorContext`) are +removed. What remains is machinery only an instantiating application +uses: `instantiate`/`instantiateEmbedder`, artifact resolution, +`requiredImports`, the import resolver and version canonicalization, +`NameCollisionError` (raised while building a facade, before any value +exists), the value-bridge/casing utilities, and the stream-pair factory +`createStream(): { stream, writer }` — the `Stream.create()` static's +new spelling, since the class is no longer exported. Minting is +application-tier by design: host modules produce streams and futures as +natural JS producers (§"Streams and futures") and never need a writer; +a host module that genuinely wants writer-driven push (`writeDirect`) is +handed one by the application, which keeps placement — like runtime +selection itself — with the deploying application. This supersedes A9's +"re-exports all of it unchanged — existing embedder code keeps working +with no import changes": applications now import the boundary vocabulary +from `@polyengine/protocol`, like everyone else. A hard break in A18's +mold (`breaking/runtime`, one lockstep minor), taken while the consumer +family is small, known, and mid-migration to the `@polyengine` scope. + +**Host modules MUST NOT import `@polyengine/runtime`** (hardening A9's +SHOULD, for published host-module packages): `@polyengine/protocol` at +most; zero-import hand-rolled brands stay legal. After the surface +removal the rule is nearly self-enforcing — the runtime exports nothing a +host module needs — and a consumer can gate it mechanically with a +one-line no-`@polyengine/runtime`-specifier check on the package. The +wasi package converts to protocol-only imports (its `instanceof Stream` +drop-on-unread checks become `isStream`), which also dissolves the +module-identity constraint consumer configs carried on its behalf ("wasi +imports `@polyengine/runtime/embedder` by bare specifier internally; map +it identically everywhere"). + +**The conventions suite is the executable definition of the host ABI.** +`runtime/tests/conventions/` exercises this contract's lift/lower +conventions against a probe host module written the way consumers write +theirs (protocol imports only) and records what the engine does — +imports-record shape, lowering-source adaptation, lifted-handle behavior, +resource conventions, the error model, suspending, error-contexts — as +normalized transcripts committed under +`runtime/tests/conventions/golden/`. The gate rule: + +- **Modifying or deleting a committed golden asserts a host-ABI behavior + change** and requires `breaking/protocol` (with the protocol minor bump + the label already implies) in the same PR. The reviewed escape for a + behavior-neutral correction — the suite itself was wrong — is the + `conventions-fix` label, same trust model as the breaking labels. +- **Adding goldens is free**: new coverage of existing behavior is not a + version event. +- version-guard enforces both at PR time (labels, advisory as ever) and + authoritatively at cut time: any M/D under + `runtime/tests/conventions/golden/` in the release window + (`git diff --name-status v..HEAD -- `) requires the + protocol version to have moved past the last cut. + +**Consequence: protocol's version is the host-ABI version.** A host +module pins `jsr:@polyengine/protocol@^0.x` and is untouched by lockstep +engine releases; an engine change that leaves the goldens byte-identical +is host-ABI-neutral *by definition*, and one that doesn't cannot ship +without announcing itself on protocol's line. + ## Bindgen obligations (summary of what the above requires) Per world: `Imports`/`Exports` types; resource classes (both directions); diff --git a/crates/bindgen/build.rs b/crates/bindgen/build.rs index 5e39df7..19df2c0 100644 --- a/crates/bindgen/build.rs +++ b/crates/bindgen/build.rs @@ -10,33 +10,49 @@ use std::path::Path; -fn main() { - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); - let manifest = Path::new(&manifest_dir).join("../../runtime/deno.json"); - - println!("cargo:rerun-if-changed=../../runtime/deno.json"); - println!("cargo:rerun-if-changed=build.rs"); - - let text = std::fs::read_to_string(&manifest).unwrap_or_else(|e| { +fn manifest_version(manifest: &Path) -> String { + let text = std::fs::read_to_string(manifest).unwrap_or_else(|e| { panic!( - "bindgen build.rs: cannot read {} (needed to derive the default \ - import base's runtime version): {e}", + "bindgen build.rs: cannot read {} (needed to derive a default \ + import base's version): {e}", manifest.display() ) }); let json: serde_json::Value = serde_json::from_str(&text).unwrap_or_else(|e| { - panic!( - "bindgen build.rs: {} is not valid JSON: {e}", - manifest.display() - ) - }); - let version = json.get("version").and_then(|v| v.as_str()).unwrap_or_else(|| { - panic!( - "bindgen build.rs: {} has no string `version` field — refusing to \ - emit a guessed default import base", - manifest.display() - ) + panic!("bindgen build.rs: {} is not valid JSON: {e}", manifest.display()) }); + json.get("version") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| { + panic!( + "bindgen build.rs: {} has no string `version` field — refusing to \ + emit a guessed default import base", + manifest.display() + ) + }) + .to_string() +} + +fn main() { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); + let runtime_manifest = Path::new(&manifest_dir).join("../../runtime/deno.json"); + // Protocol's version is independent of the lockstep runtime version + // (contracts/embedder-api.md §"The host-ABI surface and its version", + // amendment A22: "protocol's version is the host-ABI version") — the + // generated bindings' `@polyengine/protocol` import pins protocol's own + // manifest, never the runtime's. + let protocol_manifest = Path::new(&manifest_dir).join("../../protocol/deno.json"); + + println!("cargo:rerun-if-changed=../../runtime/deno.json"); + println!("cargo:rerun-if-changed=../../protocol/deno.json"); + println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rustc-env=POLYENGINE_RUNTIME_VERSION={version}"); + println!( + "cargo:rustc-env=POLYENGINE_RUNTIME_VERSION={}", + manifest_version(&runtime_manifest) + ); + println!( + "cargo:rustc-env=POLYENGINE_PROTOCOL_VERSION={}", + manifest_version(&protocol_manifest) + ); } diff --git a/crates/bindgen/src/codegen.rs b/crates/bindgen/src/codegen.rs index 354d740..7f6b7c7 100644 --- a/crates/bindgen/src/codegen.rs +++ b/crates/bindgen/src/codegen.rs @@ -41,6 +41,53 @@ pub const DEFAULT_IMPORT_BASE: &str = concat!( env!("POLYENGINE_RUNTIME_VERSION") ); +/// Default specifier for `@polyengine/protocol` imports in generated +/// bindings (amendment A22, contracts/embedder-api.md §"The host-ABI +/// surface and its version": the handle vocabulary — `Stream`/`Future`/ +/// `ErrorContext`/`ComponentException`/`Trap`/the source-union types — now +/// lives in protocol, not the runtime's embedder module). Protocol's +/// version moves independently of the runtime's lockstep version +/// (§"Consequence: protocol's version is the host-ABI version"), so this is +/// its own JSR pin derived from `protocol/deno.json` (see `build.rs`), not +/// a specifier built off `DEFAULT_IMPORT_BASE`/`--import-base`. +pub const DEFAULT_PROTOCOL_BASE: &str = concat!( + "jsr:@polyengine/protocol@^", + env!("POLYENGINE_PROTOCOL_VERSION") +); + +/// Resolve the `@polyengine/protocol` import specifier for generated +/// bindings. When `import_base` is file-addressed (the in-repo regeneration +/// path, `--import-base /runtime/src`), protocol is a *sibling* +/// package directory, not a module under the runtime's `src/` — so this +/// mirrors the path up one level and across, rather than reusing +/// `module_specifier`'s "module under this base" logic (that logic is +/// right for `plan`/`digest`/`embedder`, which really do live under +/// `{base}/{module}`; protocol does not). +/// +/// CONTRACT: the file-addressed rewrite assumes the conventional repo +/// layout (`.../runtime/src` -> `.../protocol/src`); it is exercised only by +/// the two call sites that pass an actual `runtime/src` path (the default +/// relative base and the #201 regression test's canonicalized absolute +/// path). An operator passing an unrelated file-addressed base gets the +/// bare `@polyengine/protocol` specifier instead, which still typechecks +/// for any consumer whose import map declares it (and for every in-repo +/// `deno check`, via the workspace). +fn protocol_specifier(import_base: &str) -> String { + let base = import_base.trim_end_matches('/'); + let file_addressed = base.starts_with('.') + || base.starts_with('/') + || base.starts_with("file:") + || base.starts_with("http://") + || base.starts_with("https://"); + if file_addressed { + if let Some(prefix) = base.strip_suffix("runtime/src") { + return format!("{prefix}protocol/src/mod.ts"); + } + return "@polyengine/protocol".to_string(); + } + DEFAULT_PROTOCOL_BASE.to_string() +} + /// Resolve the import specifier for one runtime module (`plan`, `digest`, /// `embedder`) against an import base (issue #201). /// @@ -107,6 +154,7 @@ pub fn generate( let plan_mod = module_specifier(import_base, "plan"); let digest_mod = module_specifier(import_base, "digest"); let embedder_mod = module_specifier(import_base, "embedder"); + let protocol_mod = protocol_specifier(import_base); writeln!(src, "// GENERATED by crates/bindgen — do not edit by hand.")?; writeln!(src, "// Source world: {}", w.name)?; @@ -141,9 +189,11 @@ pub fn generate( // Stream/Future/ErrorContext/ComponentException/Trap plus the source-union // types used at parameter positions (StreamSource/FutureSource, // contracts/embedder-api.md §"Streams and futures": "lowering accepts - // the natural JS producers") and `EmbedderInstance` (the `bind()` input - // shape) all now come from C2-A's real embedder facade module — no - // stand-in needed post-integration. + // the natural JS producers") come from `@polyengine/protocol` (amendment + // A22: the handle vocabulary moved out of the runtime's embedder + // module). `EmbedderInstance`/`EmbedderOptions`/`InstantiateSource` (the + // `bind()` input shape) stay application surface, from the embedder + // facade module. writeln!( src, "import type {{\n\ @@ -154,6 +204,11 @@ pub fn generate( \x20 ErrorContext,\n\ \x20 ComponentException,\n\ \x20 Trap,\n\ + }} from {protocol_mod:?};" + )?; + writeln!( + src, + "import type {{\n\ \x20 EmbedderInstance,\n\ \x20 EmbedderOptions,\n\ \x20 InstantiateSource,\n\ diff --git a/ct-runner/deno.json b/ct-runner/deno.json index e751d4d..b6359e9 100644 --- a/ct-runner/deno.json +++ b/ct-runner/deno.json @@ -1,6 +1,6 @@ { "name": "@polyengine/ct-runner", - "version": "0.4.1", + "version": "0.5.0", "exports": { ".": "./src/mod.ts", "./imports": "./src/import-analysis.ts", diff --git a/ct-runner/src/run-suite.ts b/ct-runner/src/run-suite.ts index 12a097d..a8aca32 100644 --- a/ct-runner/src/run-suite.ts +++ b/ct-runner/src/run-suite.ts @@ -14,9 +14,8 @@ import { type ComponentArtifacts, instantiate, - Trap, - ComponentException, } from "@polyengine/runtime/embedder"; +import { Trap, ComponentException } from "@polyengine/protocol"; import { Context, testContextImportRecord } from "./context.ts"; import { analyzeImports, requireImportsResolved } from "./import-analysis.ts"; import { diff --git a/docs/consumers.md b/docs/consumers.md index 2280d80..41dbe43 100644 --- a/docs/consumers.md +++ b/docs/consumers.md @@ -61,8 +61,10 @@ Their jco blockers map one-for-one onto this project's proven strengths Since amendment A9 (contracts/embedder-api.md §"Module identity"), cross-boundary brands are process-global symbols via `@polyengine/protocol`, so a violation degrades to a diagnosed inefficiency instead of a latent - `instanceof` failure — host modules SHOULD import `@polyengine/protocol` at - most, keeping runtime selection entirely with the deploying application. + `instanceof` failure — and since A22 (§"The host-ABI surface and its + version") host modules MUST import `@polyengine/protocol` at most (the + runtime's exported surface is application-only), keeping runtime + selection entirely with the deploying application. - **Their suites are engine sanity checks, not gates** (operator ruling, 2026-08-10; supersedes the earlier "their suites become our gates" posture and the release-gate framing of the now-closed diff --git a/examples/kitchen-sink/host.ts b/examples/kitchen-sink/host.ts index d2b3ee7..69df35a 100644 --- a/examples/kitchen-sink/host.ts +++ b/examples/kitchen-sink/host.ts @@ -19,9 +19,8 @@ import { instantiate, - suspending, - ComponentException, } from "@polyengine/runtime/embedder"; +import { suspending, ComponentException } from "@polyengine/protocol"; import { defaultTranslator } from "@polyengine/translator"; // Tiny self-checks so the example fails loudly if the API drifts. diff --git a/harness/browser/opfs_entry.ts b/harness/browser/opfs_entry.ts index ea70557..e2f9489 100644 --- a/harness/browser/opfs_entry.ts +++ b/harness/browser/opfs_entry.ts @@ -27,7 +27,8 @@ import { filesystemWeb, type OpfsDirectoryHandle } from "../../wasi/src/filesystem_web.ts"; import { wasi } from "../../wasi/src/mod.ts"; import { Translator } from "@polyengine/runtime/shim"; -import { ComponentException, instantiate } from "@polyengine/runtime/embedder"; +import { instantiate } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; interface CheckResult { name: string; diff --git a/justfile b/justfile index 54254c1..009f877 100644 --- a/justfile +++ b/justfile @@ -80,6 +80,16 @@ test-rust: test-runtime: shim fixtures corpus cd runtime && deno task check && deno task test +# The lift/lower CONVENTIONS suite alone (contracts/embedder-api.md amendment +# A22): the executable definition of the host ABI, transcripts compared against +# the committed goldens under `runtime/tests/conventions/golden/`. It lives +# under runtime/tests/, so `test-runtime` already runs it — this is the focused +# lane for working on the host boundary. Updating a golden asserts a host-ABI +# behavior change; runtime/tests/conventions/support.ts's header carries the +# update command and the labelling rule. +test-conventions: shim fixtures + cd runtime && deno test --allow-read=..,/tmp --allow-write=/tmp --allow-env=POLYENGINE_SCHED_SEED tests/conventions/ + # The brand vocabulary (contracts/embedder-api.md amendment A9): dependency- # free, so this is the one Deno suite that needs no build artifacts at all. test-protocol: diff --git a/protocol/deno.json b/protocol/deno.json index c5df557..2e54450 100644 --- a/protocol/deno.json +++ b/protocol/deno.json @@ -1,6 +1,6 @@ { "name": "@polyengine/protocol", - "version": "0.2.1", + "version": "0.2.2", "exports": { ".": "./src/mod.ts" }, diff --git a/protocol/src/brands.ts b/protocol/src/brands.ts index 57475d2..14dbd53 100644 --- a/protocol/src/brands.ts +++ b/protocol/src/brands.ts @@ -59,6 +59,15 @@ export const SUSPENDING: unique symbol = Symbol.for( export const STREAM: unique symbol = Symbol.for("polyengine.stream/1"); /** `Future.prototype` — embedder future handles (stateful: foreign = refused). */ export const FUTURE: unique symbol = Symbol.for("polyengine.future/1"); +/** + * `StreamWriter.prototype` — embedder stream writer handles (stateful: + * foreign = refused). Additive amendment A22: writers carried no brand + * before because nothing needed to recognize one, and `isStreamWriter` now + * does. + */ +export const STREAM_WRITER: unique symbol = Symbol.for( + "polyengine.streamWriter/1", +); /** Lifted error-contexts (message-valued at lowering since A20). */ export const ERROR_CONTEXT: unique symbol = Symbol.for( "polyengine.errorContext/1", diff --git a/protocol/src/cloneable.ts b/protocol/src/cloneable.ts index d2529f0..2022053 100644 --- a/protocol/src/cloneable.ts +++ b/protocol/src/cloneable.ts @@ -38,6 +38,7 @@ import { RESOURCE_STATE, STREAM, STREAM_PRODUCER, + STREAM_WRITER, TRAP, WASI_EXIT, } from "./brands.ts"; @@ -177,6 +178,13 @@ function isPassThroughExotic(v: object): boolean { * Realm-local check (A20): `isRealmLocal` (the pill), the stateful handle * brands, and resource wrappers. * + * `STREAM_WRITER` (amendment A22) is listed for consistency with the other + * stateful handle brands, not because it changes behavior here: every + * `StreamWriter` already carries the A20 pill (`defineRealmLocal` in its + * constructor, runtime/src/embedder/streams.ts), so `isRealmLocal(v)` above + * already refuses one — this is belt-and-suspenders against a hand-rolled + * writer that carries the brand but skipped the pill. + * * `RESOURCE_STATE` is checked with `!== undefined` rather than `hasBrand` * because it holds the wrapper's internal STATE object, not `true` — only the * key is contract, the shape stays runtime-internal (brands.ts). @@ -184,6 +192,7 @@ function isPassThroughExotic(v: object): boolean { function isRealmLocalValue(v: object): boolean { return isRealmLocal(v) || hasBrand(v, STREAM) || hasBrand(v, FUTURE) || hasBrand(v, POLLABLE) || + hasBrand(v, STREAM_WRITER) || (v as Record)[RESOURCE_STATE] !== undefined; } diff --git a/protocol/src/handles.ts b/protocol/src/handles.ts new file mode 100644 index 0000000..55922ba --- /dev/null +++ b/protocol/src/handles.ts @@ -0,0 +1,155 @@ +// The stream/future handle vocabulary (contracts/embedder-api.md §"Streams +// and futures"; amendment A22, §"The host-ABI surface and its version"). +// +// A22 moves the paper interfaces of §"Streams and futures" here as +// EXECUTABLE TypeScript: `Stream`, `StreamWriter`, `Future`, +// `ErrorContext`, plus the aux types `Chunk`, `DirectSource`, +// `DirectDestination`, `DirectVerdict`, `StreamSource`, `FutureSource`. +// The runtime's concrete classes (`runtime/src/embedder/streams.ts`) +// `implements` these — conformance is a compile-time assertion — and this +// package's brand predicates (below) recognize the STATEFUL values by brand, +// never by `instanceof` against those concrete classes (A9). +// +// This module stays dependency-free, like the rest of `@polyengine/protocol` +// (§"Module identity"): the interfaces are structural, referencing only +// lib.dom/lib.esnext ambient types (`ReadableStream`, `Uint8Array`, +// `PromiseLike`, `AsyncIterable`, `Iterable`). + +import { ERROR_CONTEXT, FUTURE, hasBrand, STREAM, STREAM_WRITER } from "./brands.ts"; + +/** `Chunk` is a `Uint8Array`; every other element type chunks as `T[]`. */ +export type Chunk = T extends number ? Uint8Array | T[] : T[]; + +/** + * The scoped landing zone handed to a `writeDirect` producer (amendment A21, + * polyengine#128). DEAD once the callback returns; every later method call + * throws. + */ +export interface DirectDestination { + /** + * The reader's still-unfilled bytes. Re-derived on every call (a + * `memory.grow` between two rendezvous of one session never yields a stale + * view) and shrinking by whatever has been marked so far in THIS + * invocation. + */ + remaining(): Uint8Array; + /** + * Acknowledge bytes written into the view. Cumulative within the + * invocation; acknowledged only if the callback then returns cleanly. + */ + markWritten(n: number): void; +} + +/** + * The scoped view handed to a `readDirect` consumer (amendment A21, + * polyengine#128). DEAD once the callback returns; every later method call + * throws. + */ +export interface DirectSource { + /** + * The writer's unread bytes; read-only by contract. Same scoping and + * re-derivation rules as `DirectDestination.remaining`. + */ + remaining(): Uint8Array; + /** Acknowledge bytes consumed from the view. See `markWritten`. */ + markRead(n: number): void; +} + +/** The direct-session callback's poll cadence, spelled event-style (A21). */ +export type DirectVerdict = "more" | "done"; + +/** + * A stream handle (contracts/embedder-api.md §"Streams and futures"). + * + * `read` returning an empty chunk is end-of-stream; `readable()` and the + * async iterator are built on it. `readDirect` is the A21 direct-access byte + * edge, `stream` only. + */ +export interface Stream { + /** Web-native view: `ReadableStream>`. */ + readable(): ReadableStream>; + [Symbol.asyncIterator](): AsyncIterator>; + /** Low-level read: up to `max` elements; an empty chunk means end-of-stream. */ + read(max: number): Promise>; + /** `stream` only — amendment A21, polyengine#128. */ + readDirect(consume: (src: DirectSource) => DirectVerdict): Promise; + /** Cancel an in-flight `read`. */ + cancelRead(): void; + /** `[Symbol.dispose]` alias. */ + drop(): void; + [Symbol.dispose](): void; +} + +/** Writer half of `Stream.create()` (amendment A22 brand: `streamWriter/1`). */ +export interface StreamWriter { + /** Offer values; resolves with how many the reader took. */ + write(values: Chunk): Promise; + /** `stream` only — amendment A21, polyengine#128. */ + writeDirect( + produce: (dest: DirectDestination) => DirectVerdict, + ): Promise; + /** Offer values until all are taken or the reader goes away. */ + writeAll(values: Chunk): Promise; + cancelWrite(): void; + /** End-of-stream. */ + close(): Promise; +} + +/** + * A future handle. `await`able directly (`PromiseLike`), and droppable. + * + * A future whose write end dropped without ever writing rejects with + * `DroppedError` — not `undefined`, which `future` legitimately yields. + */ +export interface Future extends PromiseLike { + drop(): void; + cancel(): void; + [Symbol.dispose](): void; +} + +/** + * `error-context` as the contract spells it: message-valued at lowering + * since amendment A20. + */ +export interface ErrorContext { + readonly message: string; +} + +/** Anything the layer accepts where a guest expects `stream`. */ +export type StreamSource = + | Stream + | ReadableStream + | AsyncIterable + | Iterable; + +/** Anything the layer accepts where a guest expects `future`. */ +export type FutureSource = Future | PromiseLike | T; + +/** Brand check: an embedder stream handle (A9; any copy, or hand-rolled). */ +export function isStream(v: unknown): v is Stream { + return hasBrand(v, STREAM); +} + +/** + * Brand check: an embedder stream writer handle (amendment A22: + * `polyengine.streamWriter/1`; any copy, or hand-rolled). + */ +export function isStreamWriter(v: unknown): v is StreamWriter { + return hasBrand(v, STREAM_WRITER); +} + +/** Brand check: an embedder future handle (A9; any copy, or hand-rolled). */ +export function isFuture(v: unknown): v is Future { + return hasBrand(v, FUTURE); +} + +/** + * Brand check: an error-context. Message-valued at lowering since amendment + * A20 — accepts any branded carrier of a string `message`, not only the + * canonical class, matching the acceptance rule §"Realm boundaries and + * structured-clone-safe forms" documents for lowering a foreign one. + */ +export function isErrorContext(v: unknown): v is ErrorContext { + return hasBrand(v, ERROR_CONTEXT) && + typeof (v as { message?: unknown }).message === "string"; +} diff --git a/protocol/src/mod.ts b/protocol/src/mod.ts index bba1270..16e4d55 100644 --- a/protocol/src/mod.ts +++ b/protocol/src/mod.ts @@ -30,6 +30,7 @@ export { RUNTIME_COPIES, STREAM, STREAM_PRODUCER, + STREAM_WRITER, SUSPENDING, TRAP, WASI_EXIT, @@ -51,6 +52,27 @@ export { ComponentException, } from "./errors.ts"; +// Stream/future handles (contracts/embedder-api.md §"Streams and futures"; +// amendment A22, §"The host-ABI surface and its version"): executable +// structural interfaces, aux types, and brand predicates. The runtime's +// concrete classes `implements` these; this package never imports them. +export { + type Chunk, + type DirectDestination, + type DirectSource, + type DirectVerdict, + type ErrorContext, + type Future, + type FutureSource, + isErrorContext, + isFuture, + isStream, + isStreamWriter, + type Stream, + type StreamSource, + type StreamWriter, +} from "./handles.ts"; + // Realm-boundary crossings (amendment A20; issue #131). The envelope TAG is // deliberately not exported: the form is version-internal, and an exported // constant invites persistence on it. diff --git a/protocol/tests/brands_test.ts b/protocol/tests/brands_test.ts index 7bd41d1..955c5e5 100644 --- a/protocol/tests/brands_test.ts +++ b/protocol/tests/brands_test.ts @@ -19,6 +19,7 @@ const EXPECTED: Record = { "polyengine.streamProducer/1": brands.STREAM_PRODUCER, "polyengine.suspending/1": brands.SUSPENDING, "polyengine.stream/1": brands.STREAM, + "polyengine.streamWriter/1": brands.STREAM_WRITER, "polyengine.future/1": brands.FUTURE, "polyengine.errorContext/1": brands.ERROR_CONTEXT, "polyengine.resourceState/1": brands.RESOURCE_STATE, diff --git a/protocol/tests/cloneable_test.ts b/protocol/tests/cloneable_test.ts index e9303da..5145bd6 100644 --- a/protocol/tests/cloneable_test.ts +++ b/protocol/tests/cloneable_test.ts @@ -25,6 +25,7 @@ import { isTrap, PeerTrappedError, STREAM, + STREAM_WRITER, StreamProducerError, toCloneable, Trap, @@ -272,6 +273,20 @@ Deno.test("A20: a STREAM-branded value is realm-local too", () => { ); }); +Deno.test("A22: a STREAM_WRITER-branded value is realm-local too", () => { + // Belt-and-suspenders (cloneable.ts `isRealmLocalValue`): a real + // `StreamWriter` already refuses via the A20 pill (`defineRealmLocal` in + // its constructor); this covers a hand-rolled writer that carries only + // the brand. + const writer = {}; + defineBrand(writer, STREAM_WRITER); + assertThrows( + () => toCloneable({ w: writer }), + InvalidHandleError, + "value.w is realm-local", + ); +}); + Deno.test("A20: functions and symbols are TypeErrors naming the path", () => { assertThrows(() => toCloneable({ f: () => {} }), TypeError, "value.f"); assertThrows( diff --git a/protocol/tests/handles_test.ts b/protocol/tests/handles_test.ts new file mode 100644 index 0000000..291185d --- /dev/null +++ b/protocol/tests/handles_test.ts @@ -0,0 +1,61 @@ +// Recognition of the stream/future handle vocabulary is by brand, not class +// (contracts/embedder-api.md §"Streams and futures", §"The host-ABI surface +// and its version", amendment A22). +// +// Same two-halves shape as errors_test.ts: a hand-rolled brand IS the value +// (any copy, or a zero-import host module), and an unbranded look-alike is +// NOT. + +import { assert, assertFalse } from "./assert.ts"; +import { + ERROR_CONTEXT, + FUTURE, + isErrorContext, + isFuture, + isStream, + isStreamWriter, + STREAM, + STREAM_WRITER, +} from "../src/mod.ts"; + +Deno.test("A22: isStream recognizes a branded value, any copy or hand-rolled", () => { + assert(isStream({ [STREAM]: true })); + assert(isStream({ [Symbol.for("polyengine.stream/1")]: true })); + assertFalse(isStream({})); + assertFalse(isStream(null)); + assertFalse(isStream(undefined)); + assertFalse(isStream({ [STREAM]: false })); +}); + +Deno.test("A22: isStreamWriter recognizes the writer brand only", () => { + assert(isStreamWriter({ [STREAM_WRITER]: true })); + assert(isStreamWriter({ [Symbol.for("polyengine.streamWriter/1")]: true })); + assertFalse(isStreamWriter({})); + // Does not cross-talk with the reader-side stream brand. + assertFalse(isStreamWriter({ [STREAM]: true })); + assertFalse(isStream({ [STREAM_WRITER]: true })); +}); + +Deno.test("A22: isFuture recognizes a branded value, any copy or hand-rolled", () => { + assert(isFuture({ [FUTURE]: true, then() {} })); + assertFalse(isFuture({ then() {} })); + assertFalse(isFuture(null)); +}); + +Deno.test("A22: isErrorContext requires the brand AND a string message (A20)", () => { + assert(isErrorContext({ [ERROR_CONTEXT]: true, message: "boom" })); + // Branded but no string message: refused — the A20 acceptance rule is + // "message-valued", not "brand alone". + assertFalse(isErrorContext({ [ERROR_CONTEXT]: true })); + assertFalse(isErrorContext({ [ERROR_CONTEXT]: true, message: 42 })); + // A string message with no brand: also refused (brand is not optional). + assertFalse(isErrorContext({ message: "boom" })); + assertFalse(isErrorContext(null)); + assertFalse(isErrorContext(undefined)); +}); + +Deno.test("A22: the three stateful brands don't cross-talk", () => { + assertFalse(isStream({ [FUTURE]: true })); + assertFalse(isFuture({ [STREAM]: true })); + assertFalse(isStreamWriter({ [Symbol.for("polyengine.errorContext/1")]: true })); +}); diff --git a/runtime/deno.json b/runtime/deno.json index 3631706..7ab698e 100644 --- a/runtime/deno.json +++ b/runtime/deno.json @@ -1,6 +1,6 @@ { "name": "@polyengine/runtime", - "version": "0.4.1", + "version": "0.5.0", "exports": { "./cache": "./src/cache/mod.ts", "./plan": "./src/plan/mod.ts", diff --git a/runtime/src/embedder/copy.ts b/runtime/src/embedder/copy.ts index 5a9ec6b..e9e0aa1 100644 --- a/runtime/src/embedder/copy.ts +++ b/runtime/src/embedder/copy.ts @@ -32,7 +32,7 @@ export const COPY_URL: string = import.meta.url; * @internal — copy-identity constant for the A9 multi-copy diagnostics; not * host-facing. */ -export const RUNTIME_VERSION = "0.4.1"; +export const RUNTIME_VERSION = "0.5.0"; /** * Compose a cross-copy diagnostic: what was foreign, which copy is speaking, diff --git a/runtime/src/embedder/mod.ts b/runtime/src/embedder/mod.ts index 7977f70..8a322e2 100644 --- a/runtime/src/embedder/mod.ts +++ b/runtime/src/embedder/mod.ts @@ -26,40 +26,13 @@ registerRuntimeCopy({ export { COPY_URL, RUNTIME_VERSION } from "./copy.ts"; -// The A9 vocabulary, re-exported unchanged: embedder code needs no import -// change, and consumers that want the multi-copy-robust spellings get them -// from the same module they already import. -export { - copyCensus, - defineRealmLocal, - DROPPED, - ERROR_CONTEXT, - fromCloneable, - FUTURE, - hasBrand, - INVALID_HANDLE, - isDroppedError, - isInvalidHandleError, - isPeerTrappedError, - isRealmLocal, - isStreamProducerError, - isSuspending, - isTrap, - isComponentException, - PEER_TRAPPED, - PROTOCOL_GENERATION, - REALM_LOCAL, - registerRuntimeCopy, - RESOURCE_STATE, - type RuntimeCopy, - runtimeCopies, - STREAM, - STREAM_PRODUCER, - SUSPENDING, - toCloneable, - TRAP, - COMPONENT_EXCEPTION, -} from "@polyengine/protocol"; +// Amendment A22 (contracts/embedder-api.md §"The host-ABI surface and its +// version"): the runtime's exported surface is application-only. The A9 +// courtesy re-exports (error classes, predicates, brands, `suspending`, +// realm crossing, the copy registry) are removed — host modules import that +// vocabulary from `@polyengine/protocol` directly. The runtime still +// registers its own copy on the census above; it just no longer hands out +// the registry API to callers of this module. export { artifactsFromEnvelope, @@ -75,42 +48,30 @@ export { export { type FuncSummary, type ImportLeaf, type PlanLike, requiredImports } from "./imports.ts"; -export { - DroppedError, - InvalidHandleError, - NameCollisionError, - PeerTrappedError, - Trap, - ComponentException, -} from "./errors.ts"; +// `NameCollisionError` is the one error class that stays here: it's raised +// while building an instantiation facade, before any handle/value exists — +// application machinery, not host-ABI vocabulary (contracts/embedder-api.md +// §"The host-ABI surface and its version", amendment A22). +export { NameCollisionError } from "./errors.ts"; -export { - type Chunk, - // Direct-access byte edges (amendment A21, polyengine#128): the two scoped - // callback objects `StreamWriter.writeDirect` / `Stream.readDirect` hand - // out, plus their verdict type. - type DirectDestination, - type DirectSource, - type DirectVerdict, - type ElemCodec, - ErrorContext, - Future, - type FutureSource, - Stream, - StreamProducerError, - type StreamSource, - StreamWriter, -} from "./streams.ts"; +export { type ElemCodec } from "./streams.ts"; + +// `createStream()` — the A22 stream-pair factory (contracts/embedder-api.md +// §"The host-ABI surface and its version" / §"Streams and futures"): the +// `Stream.create()` static's application-surface spelling, since the +// concrete `Stream`/`StreamWriter` classes are no longer exported. Handle +// TYPES are spelled against `@polyengine/protocol`'s structural interfaces. +import { Stream as InternalStream } from "./streams.ts"; +import type { Stream as ProtocolStream, StreamWriter as ProtocolStreamWriter } from "@polyengine/protocol"; + +export function createStream(): { stream: ProtocolStream; writer: ProtocolStreamWriter } { + return InternalStream.create(); +} export { GuestResource, HostResourceRegistry } from "./resources.ts"; export { camelCase, type LeafName, parseLeafName, pascalCase } from "./casing.ts"; -// Per-declaration suspendability (contracts/embedder-api.md §"Functions and -// async", amendment A1): declares that a sync-typed host import may return a -// Promise, parking the calling wasm frame (JSPI engines only). -export { suspending } from "../jspi/suspending.ts"; - export { asTrackKeySpelling, compareSemver, diff --git a/runtime/src/embedder/streams.ts b/runtime/src/embedder/streams.ts index 5d1b37e..f283b2e 100644 --- a/runtime/src/embedder/streams.ts +++ b/runtime/src/embedder/streams.ts @@ -12,10 +12,7 @@ import type { ValType } from "../cabi/types.ts"; import { despecialize } from "../cabi/types.ts"; import type { ComponentValue } from "../cabi/types.ts"; import { - type DirectDestination, type DirectSessionInfo, - type DirectSource, - type DirectVerdict, type HostFuture, hostFuture, hostFutureFor, @@ -30,20 +27,32 @@ import { poisonFailureOf, } from "../task/mod.ts"; import { + type Chunk, defineBrand, defineRealmLocal, + type DirectDestination, + type DirectSource, + type DirectVerdict, + type ErrorContext as ProtocolErrorContext, ERROR_CONTEXT, + type Future as ProtocolFuture, FUTURE, hasBrand, isStreamProducerError, STREAM, + type Stream as ProtocolStream, StreamProducerError, + STREAM_WRITER, + type StreamWriter as ProtocolStreamWriter, } from "@polyengine/protocol"; import { describeCrossCopy } from "./copy.ts"; import { DroppedError, PeerTrappedError } from "./errors.ts"; -/** `Chunk` is a `Uint8Array`; every other element type chunks as `T[]`. */ -export type Chunk = T extends number ? Uint8Array | T[] : T[]; +// `Chunk` moved to `@polyengine/protocol` (amendment A22, §"The host-ABI +// surface and its version"); re-exported here so this module's existing +// export surface (and therefore embedder/mod.ts's, unchanged in this track) +// keeps working. +export type { Chunk } from "@polyengine/protocol"; /** * Per-element adaptation, supplied by the value adapter. @@ -152,11 +161,11 @@ export function isU8Element(element: ValType | null): boolean { // Re-exported so embedders reach the A21 callback shapes from this layer too // (contracts/embedder-api.md §"Streams and futures", amendment A21, #128). -export type { - DirectDestination, - DirectSource, - DirectVerdict, -} from "../exec/host_streams.ts"; +// Canonical definitions moved to `@polyengine/protocol` with amendment A22 +// (§"The host-ABI surface and its version"); `exec/host_streams.ts` keeps its +// own structurally-identical copies for the low-level seam, so both layers +// agree without either importing the other. +export type { DirectDestination, DirectSource, DirectVerdict } from "@polyengine/protocol"; /** * A21 (#128): the direct-access byte edges are `stream` only. A @@ -177,7 +186,7 @@ function requireU8Direct(codec: ElemCodec | null, who: string): void { * `read` returning an empty chunk is end-of-stream, exactly as the contract * spells it; `readable()` and the async iterator are built on it. */ -export class Stream { +export class Stream implements ProtocolStream { #host: HostStream | null; #codec: ElemCodec | null; /** Set once the handle's shared object has been handed to a guest. */ @@ -447,7 +456,7 @@ export class Stream { const READ_CHUNK = 4096; /** Writer half of `Stream.create()`. */ -export class StreamWriter { +export class StreamWriter implements ProtocolStreamWriter { #stream: Stream; constructor(stream: Stream) { @@ -571,7 +580,7 @@ export function publishHostStream(s: Stream, h: HostStream): void { * A future whose write end dropped without ever writing rejects with * `DroppedError` — not `undefined`, which `future` legitimately yields. */ -export class Future implements PromiseLike { +export class Future implements ProtocolFuture { /** Present once the underlying host end exists. */ #host: HostFuture | null; /** Always present; resolves to the host end (immediately, when not deferred). */ @@ -759,7 +768,7 @@ export class Future implements PromiseLike { * The internal value is `task/streams.ts`'s `ErrorContext` (debug message * only, per definitions.py). */ -export class ErrorContext { +export class ErrorContext implements ProtocolErrorContext { readonly message: string; /** @internal — the internal value, preserved so it can be lowered back. */ readonly internal: InternalErrorContext; @@ -776,11 +785,15 @@ export class ErrorContext { } } -// A9 brands (contracts/embedder-api.md §"Module identity"): the three -// STATEFUL embedder-facing handle classes. Their machinery lives in the copy -// that minted them, so the brand never makes a foreign handle usable — it -// makes it DIAGNOSABLE, at the lowering sites below. +// A9 brands (contracts/embedder-api.md §"Module identity"): the STATEFUL +// embedder-facing handle classes. Their machinery lives in the copy that +// minted them, so the brand never makes a foreign handle usable — it makes +// it DIAGNOSABLE, at the lowering sites below. `StreamWriter` gains its +// brand with amendment A22 (§"The host-ABI surface and its version"): +// writers carried none before because nothing needed to recognize one, and +// `isStreamWriter` now does. defineBrand(Stream.prototype, STREAM); +defineBrand(StreamWriter.prototype, STREAM_WRITER); defineBrand(Future.prototype, FUTURE); defineBrand(ErrorContext.prototype, ERROR_CONTEXT); diff --git a/runtime/tests/bindgen/generated/async-probe.ts b/runtime/tests/bindgen/generated/async-probe.ts index f893c09..9f193b8 100644 --- a/runtime/tests/bindgen/generated/async-probe.ts +++ b/runtime/tests/bindgen/generated/async-probe.ts @@ -20,6 +20,8 @@ import type { ErrorContext, ComponentException, Trap, +} from "@polyengine/protocol"; +import type { EmbedderInstance, EmbedderOptions, InstantiateSource, diff --git a/runtime/tests/bindgen/generated/future-user.ts b/runtime/tests/bindgen/generated/future-user.ts index 72531a5..e4e3691 100644 --- a/runtime/tests/bindgen/generated/future-user.ts +++ b/runtime/tests/bindgen/generated/future-user.ts @@ -20,6 +20,8 @@ import type { ErrorContext, ComponentException, Trap, +} from "@polyengine/protocol"; +import type { EmbedderInstance, EmbedderOptions, InstantiateSource, diff --git a/runtime/tests/bindgen/generated/hello.ts b/runtime/tests/bindgen/generated/hello.ts index 5347415..70569bd 100644 --- a/runtime/tests/bindgen/generated/hello.ts +++ b/runtime/tests/bindgen/generated/hello.ts @@ -20,6 +20,8 @@ import type { ErrorContext, ComponentException, Trap, +} from "@polyengine/protocol"; +import type { EmbedderInstance, EmbedderOptions, InstantiateSource, diff --git a/runtime/tests/bindgen/generated/resources.ts b/runtime/tests/bindgen/generated/resources.ts index 0058e63..d905b55 100644 --- a/runtime/tests/bindgen/generated/resources.ts +++ b/runtime/tests/bindgen/generated/resources.ts @@ -20,6 +20,8 @@ import type { ErrorContext, ComponentException, Trap, +} from "@polyengine/protocol"; +import type { EmbedderInstance, EmbedderOptions, InstantiateSource, diff --git a/runtime/tests/bindgen/generated/stream-echo.ts b/runtime/tests/bindgen/generated/stream-echo.ts index 1cdc215..461f9b5 100644 --- a/runtime/tests/bindgen/generated/stream-echo.ts +++ b/runtime/tests/bindgen/generated/stream-echo.ts @@ -20,6 +20,8 @@ import type { ErrorContext, ComponentException, Trap, +} from "@polyengine/protocol"; +import type { EmbedderInstance, EmbedderOptions, InstantiateSource, diff --git a/runtime/tests/bindgen/generated/values.ts b/runtime/tests/bindgen/generated/values.ts index 4397b29..02b7f61 100644 --- a/runtime/tests/bindgen/generated/values.ts +++ b/runtime/tests/bindgen/generated/values.ts @@ -20,6 +20,8 @@ import type { ErrorContext, ComponentException, Trap, +} from "@polyengine/protocol"; +import type { EmbedderInstance, EmbedderOptions, InstantiateSource, diff --git a/runtime/tests/bindgen/usage/async_probe_usage.ts b/runtime/tests/bindgen/usage/async_probe_usage.ts index 2073497..f0564be 100644 --- a/runtime/tests/bindgen/usage/async_probe_usage.ts +++ b/runtime/tests/bindgen/usage/async_probe_usage.ts @@ -7,11 +7,8 @@ import { bind } from "../generated/async-probe.ts"; import type { AsyncProbeExports } from "../generated/async-probe.ts"; -import type { - EmbedderInstance, - FutureSource, - StreamSource, -} from "../../../src/embedder/mod.ts"; +import type { EmbedderInstance } from "../../../src/embedder/mod.ts"; +import type { FutureSource, StreamSource } from "@polyengine/protocol"; import type { Equal, Expect } from "./type_assert.ts"; type _WaitThenDouble = Expect< diff --git a/runtime/tests/bindgen/usage/future_user_usage.ts b/runtime/tests/bindgen/usage/future_user_usage.ts index 146c321..c56b79c 100644 --- a/runtime/tests/bindgen/usage/future_user_usage.ts +++ b/runtime/tests/bindgen/usage/future_user_usage.ts @@ -10,11 +10,8 @@ import { bind } from "../generated/future-user.ts"; import type { FutureUserExports } from "../generated/future-user.ts"; -import type { - EmbedderInstance, - Future, - FutureSource, -} from "../../../src/embedder/mod.ts"; +import type { EmbedderInstance } from "../../../src/embedder/mod.ts"; +import type { Future, FutureSource } from "@polyengine/protocol"; import type { Equal, Expect } from "./type_assert.ts"; type _DoubleFutureTakesFutureSource = Expect< diff --git a/runtime/tests/bindgen/usage/stream_echo_usage.ts b/runtime/tests/bindgen/usage/stream_echo_usage.ts index f2eb6cc..15ce45f 100644 --- a/runtime/tests/bindgen/usage/stream_echo_usage.ts +++ b/runtime/tests/bindgen/usage/stream_echo_usage.ts @@ -7,11 +7,8 @@ import { bind } from "../generated/stream-echo.ts"; import type { StreamEchoExports } from "../generated/stream-echo.ts"; -import type { - EmbedderInstance, - Stream, - StreamSource, -} from "../../../src/embedder/mod.ts"; +import type { EmbedderInstance } from "../../../src/embedder/mod.ts"; +import type { Stream, StreamSource } from "@polyengine/protocol"; import type { Equal, Expect } from "./type_assert.ts"; type _EchoDoubled = Expect< diff --git a/runtime/tests/bindgen/usage/values_usage.ts b/runtime/tests/bindgen/usage/values_usage.ts index 327d984..72f9325 100644 --- a/runtime/tests/bindgen/usage/values_usage.ts +++ b/runtime/tests/bindgen/usage/values_usage.ts @@ -12,7 +12,8 @@ import type { Shape, ValuesExports, } from "../generated/values.ts"; -import type { EmbedderInstance, ComponentException } from "../../../src/embedder/mod.ts"; +import type { EmbedderInstance } from "../../../src/embedder/mod.ts"; +import type { ComponentException } from "@polyengine/protocol"; import type { Equal, Expect } from "./type_assert.ts"; // --- record: camelCase fields ------------------------------------------- diff --git a/runtime/tests/conventions/error-context-relay.wasm b/runtime/tests/conventions/error-context-relay.wasm new file mode 100644 index 0000000..3046d0f Binary files /dev/null and b/runtime/tests/conventions/error-context-relay.wasm differ diff --git a/runtime/tests/conventions/error-context-relay.wat b/runtime/tests/conventions/error-context-relay.wat new file mode 100644 index 0000000..a2428de --- /dev/null +++ b/runtime/tests/conventions/error-context-relay.wat @@ -0,0 +1,74 @@ +;; ROW (g) fixture — an `error-context` crossing the host boundary in BOTH +;; directions (contracts/embedder-api.md §"Realm boundaries and +;; structured-clone-safe forms", amendment A20: "Error-context is +;; message-valued"). +;; +;; Nothing in the corpus puts an `error-context` in a function signature: the +;; existing `error-context.wasm` testdata component only mints and drops one +;; internally, which exercises the plan's table sections but never the lift or +;; the lower. This is the smallest component that does both. +;; +;; host:api/ec.relay: func(c: error-context) -> error-context +;; +;; `probe` mints a context whose debug message is "guest-ctx", passes it to the +;; host (the LIFT: the host must receive something `isErrorContext` recognizes, +;; carrying that message), and reads the debug message of whatever comes back +;; (the LOWER: A20 accepts any branded string-`message` carrier by minting a +;; FRESH local context). It returns that message's byte length, so one u32 +;; reports that the host's message survived the crossing. +;; +;; Regenerate: +;; wasm-tools parse runtime/tests/conventions/error-context-relay.wat \ +;; -o runtime/tests/conventions/error-context-relay.wasm +(component + (core module $Mem + (memory (export "mem") 1) + ;; The guest's own debug message, at offset 0, 9 bytes. + (data (i32.const 0) "guest-ctx") + (global $next (mut i32) (i32.const 512)) + ;; Bump allocator: `error-context.debug-message` lifts a string through it. + (func (export "realloc") + (param $old i32) (param $oldSize i32) (param $align i32) (param $new i32) + (result i32) + (local $p i32) + (local.set $p (global.get $next)) + (global.set $next (i32.add (global.get $next) (local.get $new))) + (local.get $p))) + (core instance $mem (instantiate $Mem)) + + (import "host:api/ec" (instance $api + (export "relay" (func (param "c" error-context) (result error-context))))) + (alias export $api "relay" (func $relay)) + (canon lower (func $relay) (core func $relay')) + + (core func $ec-new (canon error-context.new (memory $mem "mem"))) + (core func $ec-msg (canon error-context.debug-message + (memory $mem "mem") (realloc (func $mem "realloc")))) + (core func $ec-drop (canon error-context.drop)) + + (core module $M + (import "mem" "mem" (memory 1)) + (import "" "relay" (func $relay (param i32) (result i32))) + (import "" "ec-new" (func $ec-new (param i32 i32) (result i32))) + (import "" "ec-msg" (func $ec-msg (param i32 i32))) + (import "" "ec-drop" (func $ec-drop (param i32))) + (func (export "probe") (result i32) + (local $out i32) + ;; mint "guest-ctx" -> hand it to the host -> take the host's context + (local.set $out + (call $relay (call $ec-new (i32.const 0) (i32.const 9)))) + ;; `debug-message` writes (ptr, len) at the retptr. + (call $ec-msg (local.get $out) (i32.const 256)) + (call $ec-drop (local.get $out)) + (i32.load (i32.const 260)))) + + (core instance $i (instantiate $M + (with "mem" (instance $mem)) + (with "" (instance + (export "relay" (func $relay')) + (export "ec-new" (func $ec-new)) + (export "ec-msg" (func $ec-msg)) + (export "ec-drop" (func $ec-drop)))))) + + (func (export "probe") (result u32) + (canon lift (core func $i "probe")))) diff --git a/runtime/tests/conventions/error_context_test.ts b/runtime/tests/conventions/error_context_test.ts new file mode 100644 index 0000000..5df6316 --- /dev/null +++ b/runtime/tests/conventions/error_context_test.ts @@ -0,0 +1,116 @@ +// ROW (g) — ERROR-CONTEXT (contracts/embedder-api.md §"Realm boundaries and +// structured-clone-safe forms", amendment A20: "Error-context is +// message-valued"). +// +// An error-context's state is exactly its debug message (definitions.py), so +// A20 supersedes "lowering accepts only lifted instances": lowering accepts +// ANY branded carrier of a string `message`, minting a fresh LOCAL context — +// a new local value, never "the same" one. A branded carrier WITHOUT a string +// message keeps the loud A9 cross-copy refusal, because that shape is a +// genuinely foreign stateful handle rather than a message carrier. +// +// Fixture: `error-context-relay.wat` (this directory) — the only component +// anywhere in the tree that puts an `error-context` in a function signature. + +import { haveFixture, instantiateFixture, local } from "./harness.ts"; +import { transcript } from "./support.ts"; +import { classify, type ErrorContext } from "./probe.ts"; +import { + ERROR_CONTEXT_KEY, + handRolledErrorContext, +} from "./probe_zero_import.ts"; + +const FIXTURE = local("error-context-relay"); +const ready = await haveFixture(FIXTURE); + +Deno.test({ + name: "conventions/g: a lifted error-context is branded and carries its message", + ignore: !ready, + fn: async () => { + await transcript("g-error-context-lift", async (t) => { + let seen: unknown; + const c = await instantiateFixture(FIXTURE, { + "host:api/ec": { + relay: (ctx: ErrorContext) => { + seen = ctx; + // Hand the very same lifted value back down. + return ctx; + }, + }, + }); + // The guest returns the byte length of the message it reads back — + // "guest-ctx", 9 bytes, if the round trip preserved it. + await t.attempt("probe", () => c.exports.probe()); + t.note("host-received", { + classified: classify(seen), + message: (seen as ErrorContext).message, + }); + }); + }, +}); + +Deno.test({ + name: "conventions/g: A20 — a hand-rolled branded message carrier lowers", + ignore: !ready, + fn: async () => { + await transcript("g-error-context-message-valued", async (t) => { + const c = await instantiateFixture(FIXTURE, { + "host:api/ec": { + // Zero protocol imports on this side: the brand key spelled out by + // hand, plus a string `message`. A20 mints a fresh LOCAL context + // from it — there is nothing to alias, so identity is not in play. + relay: (_ctx: ErrorContext) => handRolledErrorContext("from-host!"), + }, + }); + // "from-host!" is 10 bytes; the guest reports what it read back. + await t.attempt("probe", () => c.exports.probe()); + }); + }, +}); + +Deno.test({ + name: "conventions/g: a branded carrier WITHOUT a string message is refused", + ignore: !ready, + fn: async () => { + await transcript("g-error-context-no-message", async (t) => { + const c = await instantiateFixture(FIXTURE, { + "host:api/ec": { + relay: (_ctx: ErrorContext) => { + // Branded, but message-less: the shape A20 leaves under A9's loud + // cross-copy refusal, because it is a foreign stateful handle + // whose machinery lives in another copy's tables. + const foreign: Record = {}; + foreign[Symbol.for(ERROR_CONTEXT_KEY)] = true; + return foreign; + }, + }, + }); + await t.attempt("probe", () => c.exports.probe()); + + // …and an entirely unbranded object is refused too, with the generic + // message: a host that returns the wrong kind of thing is a host bug. + const bare = await instantiateFixture(FIXTURE, { + "host:api/ec": { relay: (_c: ErrorContext) => ({ message: "nope" }) }, + }); + await t.attempt("probe/unbranded", () => bare.exports.probe()); + }); + }, +}); + +Deno.test({ + name: "conventions/g: isErrorContext accepts a hand-rolled carrier, rejects a husk", + fn: async () => { + await transcript("g-error-context-predicate", async (t) => { + // The vocabulary claim on its own: recognition is brand + string + // `message`, in any copy, hand-rolled or not. + t.note("hand-rolled", { + classified: classify(handRolledErrorContext("m")), + value: handRolledErrorContext("m"), + }); + const husk: Record = { message: 42 }; + husk[Symbol.for(ERROR_CONTEXT_KEY)] = true; + t.note("branded-non-string-message", { classified: classify(husk) }); + t.note("unbranded", { classified: classify({ message: "m" }) }); + }); + }, +}); diff --git a/runtime/tests/conventions/errors_test.ts b/runtime/tests/conventions/errors_test.ts new file mode 100644 index 0000000..b5a1aa5 --- /dev/null +++ b/runtime/tests/conventions/errors_test.ts @@ -0,0 +1,182 @@ +// ROW (e) — THE ERROR MODEL (contracts/embedder-api.md §"Error model"). +// +// The four claims, in one place because they are one rule seen from four +// sides: +// 1. a guest export's `result` err lifts as a `ComponentException` +// whose `payload` is the WIT err value (A10); +// 2. a host import's `throw new ComponentException(payload)` lowers to the +// guest's err case; +// 3. a HAND-ROLLED branded exception is honored identically — A9's +// zero-import legality, demonstrated by `probe_zero_import.ts`, which +// imports nothing at all; +// 4. an UNBRANDED host throw is a host BUG and becomes a trap naming the +// import — never a guest-visible err. This is the inversion of jco's +// convention, and the reason a host module needs no defensive wrapper. +// +// Plus recognition: `Trap` and `PeerTrappedError` are identified by protocol +// predicate. Trap MESSAGE text is diagnostic, not API — an engine-worded trap +// (a raw `unreachable`) is recorded by brand alone (see support.ts). + +import { guest, haveFixture, instantiateFixture, testdata } from "./harness.ts"; +import { transcript } from "./support.ts"; +import { classify, ComponentException } from "./probe.ts"; +import { handRolledException } from "./probe_zero_import.ts"; + +const HOST_RESULT = "runtime/tests/embedder/host-result.wasm"; +const HOST_PAYLOAD = "runtime/tests/embedder/host-result-payload.wasm"; + +const valuesReady = await haveFixture(guest("values")); + +Deno.test({ + name: "conventions/e: a guest err-result lifts as ComponentException(payload)", + ignore: !valuesReady, + fn: async () => { + await transcript("e-guest-err-lifts", async (t) => { + const c = await instantiateFixture(guest("values")); + // `echo-result: func(v: result) -> result`. + // As a VALUE (parameter position) a result is plain `{kind, value}` + // data that never throws; in RESULT position the same value throws. + await t.attempt("ok", () => c.exports.echoResult({ kind: "ok", value: 5 })); + await t.attempt( + "err", + () => c.exports.echoResult({ kind: "err", value: "boom" }), + ); + }); + }, +}); + +const emptyReady = await haveFixture(HOST_RESULT); + +Deno.test({ + name: "conventions/e: host ComponentException -> guest err (payloadless side)", + ignore: !emptyReady, + fn: async () => { + await transcript("e-host-throw-empty", async (t) => { + // `check: func() -> result` — both sides empty. `run()` hands the + // discriminant back: 0 = the guest saw ok, 1 = the guest saw err. + const ok = await instantiateFixture(HOST_RESULT, { + "host:api/fallible": { check: () => undefined }, + }); + await t.attempt("return-undefined", () => ok.exports.run()); + + const err = await instantiateFixture(HOST_RESULT, { + "host:api/fallible": { + check: () => { + throw new ComponentException(undefined); + }, + }, + }); + await t.attempt("throw-componentException", () => err.exports.run()); + + // A9: hand-rolled brand, zero protocol imports on the throwing side. + const hand = await instantiateFixture(HOST_RESULT, { + "host:api/fallible": { + check: () => { + throw handRolledException(undefined, "hand-rolled err"); + }, + }, + }); + await t.attempt("throw-hand-rolled", () => hand.exports.run()); + + // An UNBRANDED throw is a host bug: a trap, not an err value. The + // message is runtime-AUTHORED (stable project wording) and names the + // import, so it is recorded. + const bug = await instantiateFixture(HOST_RESULT, { + "host:api/fallible": { + check: () => { + throw new TypeError("a stray platform error"); + }, + }, + }); + // The trap's brand verdict is the transcript's `tag` — normalize() + // reads it through the protocol predicate, so the recognition claim is + // in the golden itself. + await t.attempt("throw-unbranded", () => bug.exports.run()); + }); + }, +}); + +const payloadReady = await haveFixture(HOST_PAYLOAD); + +Deno.test({ + name: "conventions/e: host ComponentException payload lowers into the err case", + ignore: !payloadReady, + fn: async () => { + await transcript("e-host-throw-payload", async (t) => { + // `try-it: func() -> result`. `run` returns `val` on ok and + // `1000 + byteLength` on err, so one u32 reports the case AND that the + // payload survived. + const ok = await instantiateFixture(HOST_PAYLOAD, { + "host:api/fallible": { tryIt: () => 12 }, + }); + await t.attempt("ok", () => ok.exports.run()); + + const err = await instantiateFixture(HOST_PAYLOAD, { + "host:api/fallible": { + tryIt: () => { + throw new ComponentException("boom"); + }, + }, + }); + await t.attempt("err/canonical", () => err.exports.run()); + + // Identical treatment for the hand-rolled brand — the point of A9. + const hand = await instantiateFixture(HOST_PAYLOAD, { + "host:api/fallible": { + tryIt: () => { + throw handRolledException("boom", "hand-rolled"); + }, + }, + }); + await t.attempt("err/hand-rolled", () => hand.exports.run()); + }); + }, +}); + +Deno.test({ + name: "conventions/e: predicates recognize a hand-rolled exception, either copy", + fn: async () => { + await transcript("e-brand-recognition", async (t) => { + // No engine involved: the vocabulary claim itself. A hand-rolled brand + // and the canonical class are the same thing to every predicate, because + // the brand is a `Symbol.for` registry symbol. + t.note("canonical", { + classified: classify(new ComponentException({ kind: "timed-out" })), + }); + t.note("hand-rolled", { + classified: classify(handRolledException({ kind: "timed-out" }, "x")), + }); + t.note("unbranded", { classified: classify(new Error("plain")) }); + // A payloadless err: `payload` is `undefined`, and the property is + // PRESENT (the empty-side spelling), which normalize() records. + t.note("payloadless", { value: new ComponentException(undefined) }); + }); + }, +}); + +const passReady = await haveFixture(guest("stream-pass")); + +Deno.test({ + name: "conventions/e: a peer TRAP surfaces as PeerTrappedError, not clean EOS", + ignore: !passReady, + fn: async () => { + await transcript("e-peer-trapped", async (t) => { + const c = await instantiateFixture(guest("stream-pass"), { + sink: (_d: unknown) => 0n, + }); + // `open-then-trap(n)`: the guest returns a stream, writes n bytes from a + // background task, then traps. A7: reads that genuinely COMPLETED keep + // their result; the fault surfaces on the handle's next operation, and + // is never presented as a clean end-of-stream. + const s = await c.exports.openThenTrap(2) as { + read(n: number): Promise; + }; + t.note("lifted", { classified: classify(s) }); + await t.attempt("read", () => s.read(8)); + // `tag: "peerTrapped"` in the golden IS the predicate verdict, and the + // walked `cause` chain is A20's requirement that the underlying fault + // stay reachable. + await t.attempt("read-after-trap", () => s.read(8)); + }); + }, +}); diff --git a/runtime/tests/conventions/golden/a-imports-record-members.jsonl b/runtime/tests/conventions/golden/a-imports-record-members.jsonl new file mode 100644 index 0000000..f471204 --- /dev/null +++ b/runtime/tests/conventions/golden/a-imports-record-members.jsonl @@ -0,0 +1,4 @@ +{"ev":"requiredImports","leaves":[{"interfaceId":"host:api/dev","jsName":"Gauge","kind":"resource","leaf":"gauge","memberForm":"plain","path":["gauge"]},{"async":false,"interfaceId":"host:api/dev","jsClass":"Gauge","jsName":"constructor","kind":"func","leaf":"[constructor]gauge","memberForm":"constructor","params":["u32"],"path":["[constructor]gauge"],"results":["own"]},{"async":false,"interfaceId":"host:api/dev","jsClass":"Gauge","jsName":"read","kind":"func","leaf":"[method]gauge.read","memberForm":"method","params":["borrow"],"path":["[method]gauge.read"],"results":["u32"]},{"async":false,"interfaceId":"host:api/dev","jsClass":"Gauge","jsName":"calibrate","kind":"func","leaf":"[static]gauge.calibrate","memberForm":"static","params":[],"path":["[static]gauge.calibrate"],"results":["u32"]}]} +{"ev":"call/probe","ok":true,"value":41} +{"ev":"call/calib","ok":true,"value":1} +{"calibrations":1,"disposed":[41],"ev":"host-observed"} diff --git a/runtime/tests/conventions/golden/a-imports-record-plain.jsonl b/runtime/tests/conventions/golden/a-imports-record-plain.jsonl new file mode 100644 index 0000000..2cbc789 --- /dev/null +++ b/runtime/tests/conventions/golden/a-imports-record-plain.jsonl @@ -0,0 +1,5 @@ +{"ev":"requiredImports","leaves":[{"async":false,"interfaceId":"log","jsName":"log","kind":"func","leaf":"log","memberForm":"plain","params":["u32"],"path":[],"results":[]},{"async":false,"interfaceId":"host:api/math","jsName":"add","kind":"func","leaf":"add","memberForm":"plain","params":["u32","u32"],"path":["add"],"results":["u32"]},{"async":false,"interfaceId":"host:api/math","jsName":"greet","kind":"func","leaf":"greet","memberForm":"plain","params":["string"],"path":["greet"],"results":["string"]}]} +{"ev":"record-keys","keys":["host:api/math","log"]} +{"ev":"call/run","ok":true,"value":42} +{"ev":"bare-import-received","logged":[42]} +{"ev":"call/greetLen","ok":true,"value":8} diff --git a/runtime/tests/conventions/golden/a-imports-record-resource.jsonl b/runtime/tests/conventions/golden/a-imports-record-resource.jsonl new file mode 100644 index 0000000..b269e1c --- /dev/null +++ b/runtime/tests/conventions/golden/a-imports-record-resource.jsonl @@ -0,0 +1,3 @@ +{"ev":"requiredImports","leaves":[{"interfaceId":"host:api/res","jsName":"R","kind":"resource","leaf":"R","memberForm":"plain","path":["R"]},{"async":false,"interfaceId":"host:api/res","jsName":"make","kind":"func","leaf":"make","memberForm":"plain","params":["u32"],"path":["make"],"results":["own"]},{"async":false,"interfaceId":"host:api/res","jsName":"value","kind":"func","leaf":"value","memberForm":"plain","params":["borrow"],"path":["value"],"results":["u32"]}]} +{"ev":"call/roundtrip","ok":true,"value":7} +{"disposed":[7],"ev":"host-observed","made":[7]} diff --git a/runtime/tests/conventions/golden/a-interface-receiver.jsonl b/runtime/tests/conventions/golden/a-interface-receiver.jsonl new file mode 100644 index 0000000..d3e32d4 --- /dev/null +++ b/runtime/tests/conventions/golden/a-interface-receiver.jsonl @@ -0,0 +1 @@ +{"ev":"call/run","ok":true,"value":142} diff --git a/runtime/tests/conventions/golden/b-a12-future-result-import.jsonl b/runtime/tests/conventions/golden/b-a12-future-result-import.jsonl new file mode 100644 index 0000000..05892c4 --- /dev/null +++ b/runtime/tests/conventions/golden/b-a12-future-result-import.jsonl @@ -0,0 +1,3 @@ +{"ev":"run-next","ok":true,"value":42} +{"ev":"run-send","ok":true,"value":4} +{"ev":"run-recv","ok":true,"value":[10,99]} diff --git a/runtime/tests/conventions/golden/b-future-handle-source.jsonl b/runtime/tests/conventions/golden/b-future-handle-source.jsonl new file mode 100644 index 0000000..8953645 --- /dev/null +++ b/runtime/tests/conventions/golden/b-future-handle-source.jsonl @@ -0,0 +1,4 @@ +{"classified":"future","ev":"export-result","value":"@future"} +{"ev":"lower-while-in-flight","ok":false,"threw":{"@err":{"message":"this Future is still in flight and cannot be passed to a guest yet","name":"TypeError","tag":"error"}}} +{"ev":"unrelated-call","ok":true,"value":2} +{"ev":"lower-after-settled","ok":true,"value":84} diff --git a/runtime/tests/conventions/golden/b-future-sources.jsonl b/runtime/tests/conventions/golden/b-future-sources.jsonl new file mode 100644 index 0000000..cbcc135 --- /dev/null +++ b/runtime/tests/conventions/golden/b-future-sources.jsonl @@ -0,0 +1,3 @@ +{"ev":"promise","ok":true,"value":42} +{"ev":"thenable","ok":true,"value":42} +{"ev":"plain-value","ok":true,"value":42} diff --git a/runtime/tests/conventions/golden/b-stream-handle-import-position.jsonl b/runtime/tests/conventions/golden/b-stream-handle-import-position.jsonl new file mode 100644 index 0000000..1d5d0f1 --- /dev/null +++ b/runtime/tests/conventions/golden/b-stream-handle-import-position.jsonl @@ -0,0 +1,2 @@ +{"ev":"forward","ok":true,"value":{"@bigint":"18"}} +{"classified":"stream","ev":"sink-argument"} diff --git a/runtime/tests/conventions/golden/b-stream-handle-source.jsonl b/runtime/tests/conventions/golden/b-stream-handle-source.jsonl new file mode 100644 index 0000000..7a96523 --- /dev/null +++ b/runtime/tests/conventions/golden/b-stream-handle-source.jsonl @@ -0,0 +1,7 @@ +{"classified":"stream","ev":"hop1"} +{"ev":"hop1/read","ok":true,"value":{"@u8":[1]}} +{"classified":"stream","ev":"hop2","sameWrapperObject":false} +{"ev":"hop1/read-after-transfer","ok":false,"threw":{"@err":{"message":"this Stream handle has already been passed to a guest; the guest owns its readable end, so it can no longer be read from the host (issue #162)","name":"TypeError","tag":"error"}}} +{"ev":"hop2/read","ok":true,"value":{"@u8":[2]}} +{"ev":"hop2/read-again","ok":true,"value":{"@u8":[3]}} +{"ev":"hop2/read-eos","ok":true,"value":{"@u8":[]}} diff --git a/runtime/tests/conventions/golden/b-stream-sources.jsonl b/runtime/tests/conventions/golden/b-stream-sources.jsonl new file mode 100644 index 0000000..b2715cc --- /dev/null +++ b/runtime/tests/conventions/golden/b-stream-sources.jsonl @@ -0,0 +1,4 @@ +{"ev":"array","ok":true,"value":{"@bigint":"10"}} +{"ev":"readable-stream","ok":true,"value":{"@bigint":"10"}} +{"ev":"async-iterable","ok":true,"value":{"@bigint":"10"}} +{"ev":"empty-array","ok":true,"value":{"@bigint":"0"}} diff --git a/runtime/tests/conventions/golden/c-lift-future-dropped.jsonl b/runtime/tests/conventions/golden/c-lift-future-dropped.jsonl new file mode 100644 index 0000000..586aea9 --- /dev/null +++ b/runtime/tests/conventions/golden/c-lift-future-dropped.jsonl @@ -0,0 +1,3 @@ +{"ev":"unrelated-call","ok":true,"value":2} +{"ev":"dropped"} +{"ev":"await-after-drop","ok":false,"threw":{"@err":{"tag":"dropped"}}} diff --git a/runtime/tests/conventions/golden/c-lift-future-eager-handle.jsonl b/runtime/tests/conventions/golden/c-lift-future-eager-handle.jsonl new file mode 100644 index 0000000..9a3b0e0 --- /dev/null +++ b/runtime/tests/conventions/golden/c-lift-future-eager-handle.jsonl @@ -0,0 +1,2 @@ +{"classified":"future","ev":"result","hasDrop":true,"hasThen":true,"isPromiseInstance":false} +{"ev":"await","ok":true,"value":8} diff --git a/runtime/tests/conventions/golden/c-lift-stream-guest-produced.jsonl b/runtime/tests/conventions/golden/c-lift-stream-guest-produced.jsonl new file mode 100644 index 0000000..949db36 --- /dev/null +++ b/runtime/tests/conventions/golden/c-lift-stream-guest-produced.jsonl @@ -0,0 +1,5 @@ +{"classified":"stream","ev":"lifted"} +{"ev":"read","ok":true,"value":[2]} +{"ev":"read","ok":true,"value":[4]} +{"ev":"read","ok":true,"value":[6]} +{"ev":"read-eos","ok":true,"value":[]} diff --git a/runtime/tests/conventions/golden/c-lift-stream-nonu8.jsonl b/runtime/tests/conventions/golden/c-lift-stream-nonu8.jsonl new file mode 100644 index 0000000..36e59b5 --- /dev/null +++ b/runtime/tests/conventions/golden/c-lift-stream-nonu8.jsonl @@ -0,0 +1,4 @@ +{"classified":"stream","ev":"lifted"} +{"ev":"read","ok":true,"value":["a"]} +{"ev":"read","ok":true,"value":["b"]} +{"ev":"read-eos","ok":true,"value":[]} diff --git a/runtime/tests/conventions/golden/c-lift-stream-u8-iterate.jsonl b/runtime/tests/conventions/golden/c-lift-stream-u8-iterate.jsonl new file mode 100644 index 0000000..4b85206 --- /dev/null +++ b/runtime/tests/conventions/golden/c-lift-stream-u8-iterate.jsonl @@ -0,0 +1,5 @@ +{"classified":"stream","ev":"lifted"} +{"chunk":{"@u8":[1]},"ev":"chunk"} +{"chunk":{"@u8":[2]},"ev":"chunk"} +{"chunk":{"@u8":[3]},"ev":"chunk"} +{"ev":"iteration-ended"} diff --git a/runtime/tests/conventions/golden/c-lift-stream-u8-read.jsonl b/runtime/tests/conventions/golden/c-lift-stream-u8-read.jsonl new file mode 100644 index 0000000..d561e91 --- /dev/null +++ b/runtime/tests/conventions/golden/c-lift-stream-u8-read.jsonl @@ -0,0 +1,5 @@ +{"classified":"stream","ev":"lifted"} +{"ev":"read","ok":true,"value":{"@u8":[10]}} +{"ev":"read","ok":true,"value":{"@u8":[20]}} +{"ev":"read","ok":true,"value":{"@u8":[30]}} +{"ev":"read-eos","ok":true,"value":{"@u8":[]}} diff --git a/runtime/tests/conventions/golden/d-host-resource-members.jsonl b/runtime/tests/conventions/golden/d-host-resource-members.jsonl new file mode 100644 index 0000000..23c3e70 --- /dev/null +++ b/runtime/tests/conventions/golden/d-host-resource-members.jsonl @@ -0,0 +1,5 @@ +{"ev":"probe","ok":true,"value":41} +{"disposed":[41],"ev":"after-probe"} +{"ev":"calib","ok":true,"value":1} +{"ev":"calib-again","ok":true,"value":2} +{"calibrations":2,"ev":"statics"} diff --git a/runtime/tests/conventions/golden/d-host-resource-plain-class.jsonl b/runtime/tests/conventions/golden/d-host-resource-plain-class.jsonl new file mode 100644 index 0000000..53de365 --- /dev/null +++ b/runtime/tests/conventions/golden/d-host-resource-plain-class.jsonl @@ -0,0 +1,6 @@ +{"ev":"roundtrip","ok":true,"value":7} +{"disposed":[7],"ev":"effects","events":["make(7)","value(self.v=7, isCell=true)"]} +{"ev":"make-and-keep","ok":true,"value":1} +{"disposed":[7],"ev":"before-guest-drop"} +{"ev":"drop-handle","ok":true,"value":"@undefined"} +{"disposed":[7,9],"ev":"after-guest-drop"} diff --git a/runtime/tests/conventions/golden/d-interface-provider-class.jsonl b/runtime/tests/conventions/golden/d-interface-provider-class.jsonl new file mode 100644 index 0000000..2a35664 --- /dev/null +++ b/runtime/tests/conventions/golden/d-interface-provider-class.jsonl @@ -0,0 +1,3 @@ +{"ev":"run","ok":true,"value":7} +{"ev":"run/object-literal","ok":true,"value":2} +{"ev":"greetLen/object-literal","ok":true,"value":5} diff --git a/runtime/tests/conventions/golden/e-brand-recognition.jsonl b/runtime/tests/conventions/golden/e-brand-recognition.jsonl new file mode 100644 index 0000000..d6bb522 --- /dev/null +++ b/runtime/tests/conventions/golden/e-brand-recognition.jsonl @@ -0,0 +1,4 @@ +{"classified":"componentException","ev":"canonical"} +{"classified":"componentException","ev":"hand-rolled"} +{"classified":"unbranded-error","ev":"unbranded"} +{"ev":"payloadless","value":{"@err":{"message":"component error: undefined","payload":"@undefined","tag":"componentException"}}} diff --git a/runtime/tests/conventions/golden/e-guest-err-lifts.jsonl b/runtime/tests/conventions/golden/e-guest-err-lifts.jsonl new file mode 100644 index 0000000..2bd1509 --- /dev/null +++ b/runtime/tests/conventions/golden/e-guest-err-lifts.jsonl @@ -0,0 +1,2 @@ +{"ev":"ok","ok":true,"value":5} +{"ev":"err","ok":false,"threw":{"@err":{"message":"component error: boom","payload":"boom","tag":"componentException"}}} diff --git a/runtime/tests/conventions/golden/e-host-throw-empty.jsonl b/runtime/tests/conventions/golden/e-host-throw-empty.jsonl new file mode 100644 index 0000000..dfb67fc --- /dev/null +++ b/runtime/tests/conventions/golden/e-host-throw-empty.jsonl @@ -0,0 +1,4 @@ +{"ev":"return-undefined","ok":true,"value":0} +{"ev":"throw-componentException","ok":true,"value":1} +{"ev":"throw-hand-rolled","ok":true,"value":1} +{"ev":"throw-unbranded","ok":false,"threw":{"@err":{"message":"import 'host:api/fallible/check' threw TypeError: a stray platform error. An unbranded throw from a host import is a host bug and becomes a trap: signal a WIT error with `throw new ComponentException(payload)`.","tag":"trap"}}} diff --git a/runtime/tests/conventions/golden/e-host-throw-payload.jsonl b/runtime/tests/conventions/golden/e-host-throw-payload.jsonl new file mode 100644 index 0000000..5842ff8 --- /dev/null +++ b/runtime/tests/conventions/golden/e-host-throw-payload.jsonl @@ -0,0 +1,3 @@ +{"ev":"ok","ok":true,"value":12} +{"ev":"err/canonical","ok":true,"value":1004} +{"ev":"err/hand-rolled","ok":true,"value":1004} diff --git a/runtime/tests/conventions/golden/e-peer-trapped.jsonl b/runtime/tests/conventions/golden/e-peer-trapped.jsonl new file mode 100644 index 0000000..b2fbc5c --- /dev/null +++ b/runtime/tests/conventions/golden/e-peer-trapped.jsonl @@ -0,0 +1,3 @@ +{"classified":"stream","ev":"lifted"} +{"ev":"read","ok":true,"value":{"@u8":[7,7]}} +{"ev":"read-after-trap","ok":false,"threw":{"@err":{"cause":{"@err":{"cause":{"@err":{"message":"","tag":"trap"}},"message":"component instance 0 trapped while it held an end of this stream/future; the peer can never rendezvous again","name":"Error","tag":"error"}},"message":"open-then-trap: the peer component instance trapped, so this stream/future operation can never complete — component instance 0 trapped while it held an end of this stream/future; the peer can never rendezvous again","tag":"peerTrapped"}}} diff --git a/runtime/tests/conventions/golden/f-suspending-jspi-false.jsonl b/runtime/tests/conventions/golden/f-suspending-jspi-false.jsonl new file mode 100644 index 0000000..7b773ca --- /dev/null +++ b/runtime/tests/conventions/golden/f-suspending-jspi-false.jsonl @@ -0,0 +1 @@ +{"ev":"run","ok":false,"threw":{"@err":{"message":"needs JSPI (M2 phase 3): synchronous lower of import 'host:api/math/add', whose host implementation returned a Promise (the guest's wasm frame must block)","name":"NeedsJspi","tag":"error"}}} diff --git a/runtime/tests/conventions/golden/f-suspending-plain-import.jsonl b/runtime/tests/conventions/golden/f-suspending-plain-import.jsonl new file mode 100644 index 0000000..6b9114c --- /dev/null +++ b/runtime/tests/conventions/golden/f-suspending-plain-import.jsonl @@ -0,0 +1,3 @@ +{"ev":"run","ok":true,"value":42} +{"ev":"run/marked-but-sync","ok":true,"value":42} +{"ev":"run/hand-rolled-mark","ok":true,"value":42} diff --git a/runtime/tests/conventions/golden/f-suspending-prototype-relay.jsonl b/runtime/tests/conventions/golden/f-suspending-prototype-relay.jsonl new file mode 100644 index 0000000..7f876bf --- /dev/null +++ b/runtime/tests/conventions/golden/f-suspending-prototype-relay.jsonl @@ -0,0 +1,3 @@ +{"ev":"probe","ok":true,"value":41} +{"disposed":[41],"ev":"dtor"} +{"ev":"probe/unmarked-sync","ok":true,"value":5} diff --git a/runtime/tests/conventions/golden/f-suspending-unmarked-refusal.jsonl b/runtime/tests/conventions/golden/f-suspending-unmarked-refusal.jsonl new file mode 100644 index 0000000..c5f97a4 --- /dev/null +++ b/runtime/tests/conventions/golden/f-suspending-unmarked-refusal.jsonl @@ -0,0 +1 @@ +{"ev":"run","ok":false,"threw":{"@err":{"message":"needs JSPI (M2 phase 3): synchronous lower of import 'host:api/math/add', whose host implementation returned a Promise; a sync-typed import may only park the frame when declared with suspending() (contracts/embedder-api.md §\"Functions and async\")","name":"NeedsJspi","tag":"error"}}} diff --git a/runtime/tests/conventions/golden/g-error-context-lift.jsonl b/runtime/tests/conventions/golden/g-error-context-lift.jsonl new file mode 100644 index 0000000..ac17f8b --- /dev/null +++ b/runtime/tests/conventions/golden/g-error-context-lift.jsonl @@ -0,0 +1,2 @@ +{"ev":"probe","ok":true,"value":9} +{"classified":"errorContext","ev":"host-received","message":"guest-ctx"} diff --git a/runtime/tests/conventions/golden/g-error-context-message-valued.jsonl b/runtime/tests/conventions/golden/g-error-context-message-valued.jsonl new file mode 100644 index 0000000..c1695e6 --- /dev/null +++ b/runtime/tests/conventions/golden/g-error-context-message-valued.jsonl @@ -0,0 +1 @@ +{"ev":"probe","ok":true,"value":10} diff --git a/runtime/tests/conventions/golden/g-error-context-no-message.jsonl b/runtime/tests/conventions/golden/g-error-context-no-message.jsonl new file mode 100644 index 0000000..6e524b8 --- /dev/null +++ b/runtime/tests/conventions/golden/g-error-context-no-message.jsonl @@ -0,0 +1,2 @@ +{"ev":"probe","ok":false,"threw":{"@err":{"message":"import 'host:api/ec/relay': this error-context was minted by a DIFFERENT polyengine runtime copy and cannot be used through this one (this copy: ). Handles are stateful — their machinery lives in the copy that minted them (contracts/embedder-api.md amendment A9, issue #83)","name":"TypeError","tag":"error"}}} +{"ev":"probe/unbranded","ok":false,"threw":{"@err":{"message":"import 'host:api/ec/relay': expected an ErrorContext","name":"TypeError","tag":"error"}}} diff --git a/runtime/tests/conventions/golden/g-error-context-predicate.jsonl b/runtime/tests/conventions/golden/g-error-context-predicate.jsonl new file mode 100644 index 0000000..8520880 --- /dev/null +++ b/runtime/tests/conventions/golden/g-error-context-predicate.jsonl @@ -0,0 +1,3 @@ +{"classified":"errorContext","ev":"hand-rolled","value":{"@errorContext":"m"}} +{"classified":"object","ev":"branded-non-string-message"} +{"classified":"object","ev":"unbranded"} diff --git a/runtime/tests/conventions/harness.ts b/runtime/tests/conventions/harness.ts new file mode 100644 index 0000000..e34f0d9 --- /dev/null +++ b/runtime/tests/conventions/harness.ts @@ -0,0 +1,78 @@ +// The APPLICATION side of the conventions suite: translate a fixture and +// instantiate it. This is the only file here allowed to touch +// `@polyengine/runtime` — the harness plays the embedding application, whose +// job (instantiate, resolve artifacts, enumerate `requiredImports`) is exactly +// what `@polyengine/runtime/embedder`'s surface is for after amendment A22. +// +// The PROBE HOST MODULE (`probe.ts`) is the other side, and imports none of +// it. Keep the split: a runtime import leaking into probe.ts would void the +// property this suite exists to demonstrate. + +import { Translator } from "@polyengine/runtime/shim"; +import { + type ComponentArtifacts, + type EmbedderInstance, + type EmbedderOptions, + instantiate, + requiredImports, +} from "@polyengine/runtime/embedder"; + +// Re-exported so this file stays the SINGLE place the suite touches the +// runtime: `rg 'from "@polyengine/runtime' runtime/tests/conventions/` must +// list harness.ts and nothing else. +export { requiredImports }; + +const root = new URL("../../../", import.meta.url); + +async function read(rel: string): Promise { + try { + return await Deno.readFile(new URL(rel, root)); + } catch { + return null; + } +} + +const shimWasm = await read( + "target/wasm32-unknown-unknown/release/translator_shim.wasm", +); +const translator = shimWasm === null ? null : await Translator.create(shimWasm); + +/** True when the shim and `rel` are both present; cases self-skip otherwise. */ +export async function haveFixture(rel: string): Promise { + return translator !== null && (await read(rel)) !== null; +} + +export async function artifactsOf(rel: string): Promise { + const componentBytes = (await read(rel))!; + const { plan, adapters } = translator!.translate(componentBytes); + return { plan, componentBytes, adapters }; +} + +export async function instantiateFixture( + rel: string, + imports: Record = {}, + opts: EmbedderOptions = {}, +): Promise { + return await instantiate(await artifactsOf(rel), imports, opts); +} + +/** A built guest component from `examples/guests`. */ +export function guest(name: string): string { + return `examples/guests/build/${name}.component.wasm`; +} + +/** A hand-written `.wat` fixture from the translator-shim corpus. */ +export function testdata(name: string): string { + return `crates/translator-shim/testdata/${name}.wasm`; +} + +/** A `.wat` fixture owned by THIS suite (committed alongside its `.wasm`). */ +export function local(name: string): string { + return `runtime/tests/conventions/${name}.wasm`; +} + +/** JSPI is the engine floor for the `suspending()` row (A1). */ +export function jspiSupported(): boolean { + return typeof (WebAssembly as { Suspending?: unknown }).Suspending === + "function"; +} diff --git a/runtime/tests/conventions/imports_shape_test.ts b/runtime/tests/conventions/imports_shape_test.ts new file mode 100644 index 0000000..3ee5bae --- /dev/null +++ b/runtime/tests/conventions/imports_shape_test.ts @@ -0,0 +1,151 @@ +// ROW (a) — the imports record's SHAPE (contracts/embedder-api.md §"Module +// wiring and instantiation"): one nested record keyed by verbatim interface +// id, world-level bare imports at the top level, leaves under their camelCase +// JS names, resource CLASSES at the resource's position, and mangled member +// leaves (`[method]r.m`, `[static]r.m`, `[constructor]r`) dispatching on that +// class. +// +// The transcript pins BOTH halves and their agreement: what `requiredImports` +// says the component needs, and the record that actually satisfies it. Those +// two drifting apart is the failure this row exists to catch — an embedder +// reads the first and writes the second. + +import { + artifactsOf, + haveFixture, + instantiateFixture, + requiredImports, + testdata, +} from "./harness.ts"; +import { transcript } from "./support.ts"; +import { Cell, Gauge, MathProvider } from "./probe.ts"; + +/** The leaf projection the transcript records: contract fields only. */ +// deno-lint-ignore no-explicit-any +function leafRow(l: any): Record { + const row: Record = { + interfaceId: l.interfaceId, + path: l.path, + leaf: l.leaf, + kind: l.kind, + jsName: l.jsName, + memberForm: l.member.form, + }; + if (l.jsClass !== undefined) row.jsClass = l.jsClass; + if (l.type) { + // Param NAMES are docs-only (§"Functions and async": excluded from the + // world digest) but the ARITY and types are the linkable shape. + // deno-lint-ignore no-explicit-any + row.params = l.type.params.map((p: any) => p.type.kind); + // deno-lint-ignore no-explicit-any + row.results = l.type.results.map((r: any) => r.kind); + row.async = l.type.async; + } + return row; +} + +const importsReady = await haveFixture(testdata("imports")); + +Deno.test({ + name: "conventions/a: imports record — bare + interface leaves, camelCase", + ignore: !importsReady, + fn: async () => { + await transcript("a-imports-record-plain", async (t) => { + const leaves = requiredImports(await artifactsOf(testdata("imports"))); + t.note("requiredImports", { leaves: leaves.map(leafRow) }); + + const logged: number[] = []; + // The canonical form: a world-level bare import at the top level, an + // interface import keyed by its verbatim WIT id. The interface provider + // is a CLASS INSTANCE (A2) whose `add` reads instance state, so a + // mis-bound receiver would show up as a wrong answer, not a pass. + const imports = { + log: (x: number) => void logged.push(x), + "host:api/math": new MathProvider(0), + }; + t.note("record-keys", { keys: Object.keys(imports).sort() }); + + const c = await instantiateFixture(testdata("imports"), imports); + await t.attempt("call/run", () => c.exports.run(2, 40)); + t.note("bare-import-received", { logged }); + await t.attempt("call/greetLen", () => c.exports.greetLen()); + }); + }, +}); + +Deno.test({ + name: "conventions/a: A2 — an interface member's receiver is its provider", + ignore: !importsReady, + fn: async () => { + await transcript("a-interface-receiver", async (t) => { + // Same component, a provider carrying instance state. A world-level bare + // import has no containing object and is called unbound; an interface + // member is invoked with the provider as receiver. + const c = await instantiateFixture(testdata("imports"), { + log: () => {}, + "host:api/math": new MathProvider(100), + }); + // 2 + 40 + bias(100) + await t.attempt("call/run", () => c.exports.run(2, 40)); + }); + }, +}); + +const resReady = await haveFixture(testdata("imported-resource")); + +Deno.test({ + name: "conventions/a: imports record — a resource CLASS at the type's slot", + ignore: !resReady, + fn: async () => { + await transcript("a-imports-record-resource", async (t) => { + const leaves = requiredImports( + await artifactsOf(testdata("imported-resource")), + ); + t.note("requiredImports", { leaves: leaves.map(leafRow) }); + + Cell.reset(); + const c = await instantiateFixture(testdata("imported-resource"), { + "host:api/res": { + // The resource CLASS sits at the resource's position — no rep + // token, no side table (§"Resources", C0 findings 1-3). + R: Cell, + make: (v: number) => new Cell(v), + value: (r: Cell) => r.v, + }, + }); + await t.attempt("call/roundtrip", () => c.exports.roundtrip(7)); + t.note("host-observed", { made: Cell.made, disposed: Cell.disposed }); + }); + }, +}); + +const gaugeReady = await haveFixture( + "runtime/tests/embedder/suspending-method.wasm", +); + +Deno.test({ + name: "conventions/a: imports record — mangled member leaves, one class", + ignore: !gaugeReady, + fn: async () => { + await transcript("a-imports-record-members", async (t) => { + const leaves = requiredImports( + await artifactsOf("runtime/tests/embedder/suspending-method.wasm"), + ); + // `[constructor]gauge`, `[method]gauge.read`, `[static]gauge.calibrate` + // all carry `jsClass: "Gauge"` and dispatch on the ONE class entry. + t.note("requiredImports", { leaves: leaves.map(leafRow) }); + + Gauge.reset(); + const c = await instantiateFixture( + "runtime/tests/embedder/suspending-method.wasm", + { "host:api/dev": { Gauge } }, + ); + await t.attempt("call/probe", () => c.exports.probe(41)); + await t.attempt("call/calib", () => c.exports.calib()); + t.note("host-observed", { + calibrations: Gauge.calibrations, + disposed: Gauge.disposed, + }); + }); + }, +}); diff --git a/runtime/tests/conventions/lifting_test.ts b/runtime/tests/conventions/lifting_test.ts new file mode 100644 index 0000000..8776988 --- /dev/null +++ b/runtime/tests/conventions/lifting_test.ts @@ -0,0 +1,148 @@ +// ROW (c) — LIFTING (contracts/embedder-api.md §"Streams and futures"). +// What the host RECEIVES: lifted `stream`/`future` arrive branded (the +// protocol predicates recognize them — never `instanceof` against an engine +// class, which A9 removed from the contract); `stream` chunks are +// `Uint8Array` through both `read(max)` and async iteration while every other +// element type chunks as `T[]` (the `Chunk` rule, A5); an export whose WIT +// result is `future` returns an EAGER handle, not `Promise` (C2); +// and awaiting a future whose write end dropped without a value rejects +// `DroppedError` — "no value, ever" discriminated from `future`'s +// legitimate `undefined`. + +import { guest, haveFixture, instantiateFixture } from "./harness.ts"; +import { transcript } from "./support.ts"; +import { classify } from "./probe.ts"; + +const passReady = await haveFixture(guest("stream-pass")); + +Deno.test({ + name: "conventions/c: a lifted stream chunks as Uint8Array — read(max)", + ignore: !passReady, + fn: async () => { + await transcript("c-lift-stream-u8-read", async (t) => { + const c = await instantiateFixture(guest("stream-pass"), { + sink: (_d: unknown) => 0n, + }); + const s = await c.exports.passThrough([10, 20, 30]) as { + read(n: number): Promise; + }; + // The predicate is the recognition, and it is the ONLY one this suite + // will accept: a brand check that any copy of protocol agrees with. + t.note("lifted", { classified: classify(s) }); + await t.attempt("read", () => s.read(8)); + await t.attempt("read", () => s.read(8)); + await t.attempt("read", () => s.read(8)); + // An empty chunk is end-of-stream (never `undefined`, never a throw). + await t.attempt("read-eos", () => s.read(8)); + }); + }, +}); + +Deno.test({ + name: "conventions/c: a lifted stream chunks as Uint8Array — async iteration", + ignore: !passReady, + fn: async () => { + await transcript("c-lift-stream-u8-iterate", async (t) => { + const c = await instantiateFixture(guest("stream-pass"), { + sink: (_d: unknown) => 0n, + }); + const s = await c.exports.passThrough([1, 2, 3]) as AsyncIterable< + unknown + >; + t.note("lifted", { classified: classify(s) }); + for await (const chunk of s) t.note("chunk", { chunk }); + t.note("iteration-ended"); + }); + }, +}); + +Deno.test({ + name: "conventions/c: a lifted stream chunks as T[], not Uint8Array", + ignore: !passReady, + fn: async () => { + await transcript("c-lift-stream-nonu8", async (t) => { + const c = await instantiateFixture(guest("stream-pass"), { + sink: (_d: unknown) => 0n, + }); + // §"Value mapping": `list` for T ≠ u8 is a plain array, and the same + // rule governs chunks — no typed-array widening, ever silently. + const s = await c.exports.passThroughText(["a", "b"]) as { + read(n: number): Promise; + }; + t.note("lifted", { classified: classify(s) }); + await t.attempt("read", () => s.read(8)); + await t.attempt("read", () => s.read(8)); + await t.attempt("read-eos", () => s.read(8)); + }); + }, +}); + +const echoReady = await haveFixture(guest("stream-echo")); + +Deno.test({ + name: "conventions/c: a guest-PRODUCED stream lifts as a branded handle", + ignore: !echoReady, + fn: async () => { + await transcript("c-lift-stream-guest-produced", async (t) => { + const c = await instantiateFixture(guest("stream-echo")); + // The guest forwards in a background task and returns the output stream + // immediately, so this is a genuine guest-minted end, not a pass-through. + const out = await c.exports.echoDoubled([1, 2, 3]) as { + read(n: number): Promise; + }; + t.note("lifted", { classified: classify(out) }); + await t.attempt("read", () => out.read(4)); + await t.attempt("read", () => out.read(4)); + await t.attempt("read", () => out.read(4)); + await t.attempt("read-eos", () => out.read(4)); + }); + }, +}); + +const futureUserReady = await haveFixture(guest("future-user")); + +Deno.test({ + name: "conventions/c: C2 — a future RESULT is an eager handle, not a Promise", + ignore: !futureUserReady, + fn: async () => { + await transcript("c-lift-future-eager-handle", async (t) => { + const c = await instantiateFixture(guest("future-user")); + const f = c.exports.makeFuture(7) as PromiseLike & { + drop(): void; + }; + // Not awaited. JS promise resolution unconditionally adopts thenables, + // so a `Promise` could never resolve TO the handle — `drop`/ + // `cancel` would be unreachable. Hence the eager return. + t.note("result", { + classified: classify(f), + isPromiseInstance: f instanceof Promise, + hasThen: typeof f.then === "function", + hasDrop: typeof f.drop === "function", + }); + // It is PromiseLike, so `await` still yields T. + await t.attempt("await", () => f); + }); + }, +}); + +Deno.test({ + name: "conventions/c: awaiting a DROPPED-without-value future rejects DroppedError", + ignore: !futureUserReady, + fn: async () => { + await transcript("c-lift-future-dropped", async (t) => { + const c = await instantiateFixture(guest("future-user")); + const f = c.exports.makeFuture(1) as PromiseLike & { + drop(): void; + }; + // Reach quiescence with one unrelated task, so the producing call has + // completed and the handle's host end exists (A16's deferred rule). + await t.attempt("unrelated-call", () => c.exports.doubleFuture(1)); + // A16: `drop()` is a plain handle operation — total and silent. + f.drop(); + t.note("dropped"); + // "No value, ever" is a different outcome from `future`'s + // `undefined`, and the contract discriminates it. + await t.attempt("await-after-drop", () => Promise.resolve(f)); + }); + }, +}); diff --git a/runtime/tests/conventions/lowering_test.ts b/runtime/tests/conventions/lowering_test.ts new file mode 100644 index 0000000..4eedf1a --- /dev/null +++ b/runtime/tests/conventions/lowering_test.ts @@ -0,0 +1,210 @@ +// ROW (b) — LOWERING SOURCES (contracts/embedder-api.md §"Streams and +// futures": "Lowering accepts the natural JS producers"). Where the guest +// expects `stream` the host may pass an array, a `ReadableStream`, an +// `AsyncIterable`, or a `Stream` handle; where it expects `future`, a +// `Promise`, a `Future` handle, or any thenable. Plus amendment A12: an import +// whose WIT RESULT is `future` returns the future SOURCE — the call +// completes immediately and the future settles on the producer's schedule. +// +// Determinism note: every case drives exactly one guest task and awaits it to +// completion. The transcripts record the values that crossed, never the order +// in which independent tasks got scheduled. + +import { guest, haveFixture, instantiateFixture } from "./harness.ts"; +import { transcript } from "./support.ts"; +import { asyncIterable, classify, readable, thenable } from "./probe.ts"; + +const probeReady = await haveFixture(guest("async-probe")); + +Deno.test({ + name: "conventions/b: stream lowering sources — array, RS, async iter", + ignore: !probeReady, + fn: async () => { + await transcript("b-stream-sources", async (t) => { + const c = await instantiateFixture(guest("async-probe")); + // One export, four spellings of the same payload. Equal sums are the + // whole claim: the adaptation is a source question, never a value one. + await t.attempt("array", () => c.exports.sumStream([1, 2, 3, 4])); + await t.attempt( + "readable-stream", + () => c.exports.sumStream(readable([1, 2, 3, 4])), + ); + await t.attempt( + "async-iterable", + () => c.exports.sumStream(asyncIterable([1, 2, 3, 4])), + ); + // An empty finite source is end-of-stream immediately, not a hang. + await t.attempt("empty-array", () => c.exports.sumStream([])); + }); + }, +}); + +Deno.test({ + name: "conventions/b: future lowering sources — Promise, thenable", + ignore: !probeReady, + fn: async () => { + await transcript("b-future-sources", async (t) => { + const c = await instantiateFixture(guest("async-probe")); + await t.attempt( + "promise", + () => c.exports.futureAdd(Promise.resolve(40), 2), + ); + // A PLAIN THENABLE — not a Promise, not a handle. The contract names + // `Promise` and `Future`; a thenable is the shape JS treats as + // interchangeable with a Promise everywhere else, so what the engine + // does with one is worth pinning either way. + await t.attempt("thenable", () => c.exports.futureAdd(thenable(40), 2)); + // An immediate (non-thenable) value in future position. + await t.attempt("plain-value", () => c.exports.futureAdd(40, 2)); + }); + }, +}); + +const futureUserReady = await haveFixture(guest("future-user")); + +Deno.test({ + name: "conventions/b: a Future HANDLE is a lowering source (same store)", + ignore: !futureUserReady, + fn: async () => { + await transcript("b-future-handle-source", async (t) => { + const c = await instantiateFixture(guest("future-user")); + // C2: an export whose WIT result is `future` returns the handle + // EAGERLY — call without awaiting to hold it. + const f = c.exports.makeFuture(41) as unknown; + t.note("export-result", { classified: classify(f), value: f }); + + // A16: such a handle is DEFERRED — its host end materializes when the + // producing call completes. Lowering it before then is refused, loudly. + await t.attempt("lower-while-in-flight", () => c.exports.doubleFuture(f)); + + // Drive the instance to quiescence with an unrelated single task, so the + // producing call has completed. (Deterministic: the probe task's own + // completion is what is awaited, and the producer needs no further host + // action.) + await t.attempt("unrelated-call", () => c.exports.doubleFuture(1)); + await t.attempt("lower-after-settled", () => c.exports.doubleFuture(f)); + }); + }, +}); + +const passReady = await haveFixture(guest("stream-pass")); + +Deno.test({ + name: "conventions/b: a Stream HANDLE is a lowering source (A5 round trip)", + ignore: !passReady, + fn: async () => { + await transcript("b-stream-handle-source", async (t) => { + const c = await instantiateFixture(guest("stream-pass"), { + sink: (_data: unknown) => 0n, + }); + // Hop 1: an array lowers in, the guest hands the readable end straight + // back out without reading it. What arrives is a Stream handle. + const s1 = await c.exports.passThrough([1, 2, 3]) as { + read(n: number): Promise; + }; + t.note("hop1", { classified: classify(s1) }); + // Read ONE element off it, so the end carries observable position. + await t.attempt("hop1/read", () => s1.read(8)); + + // Hop 2: that HANDLE is the lowering source. A5: lifting a stream the + // host already handled is idempotent — a handle over the same underlying + // END. Note it is NOT the same wrapper OBJECT: the contract promises an + // end, and the engine mints a fresh wrapper per lift. + const s2 = await c.exports.passThrough(s1) as typeof s1; + t.note("hop2", { + classified: classify(s2), + sameWrapperObject: (s2 as unknown) === (s1 as unknown), + }); + + // A15's companion refusal: s1's end went to the guest, so a host read + // through the old handle would operate a phantom duplicate. + await t.attempt("hop1/read-after-transfer", () => s1.read(8)); + + // The identity proof: s2 resumes where s1 stopped — same end, and the + // payload never touched guest memory. + await t.attempt("hop2/read", () => s2.read(8)); + await t.attempt("hop2/read-again", () => s2.read(8)); + await t.attempt("hop2/read-eos", () => s2.read(8)); + }); + }, +}); + +Deno.test({ + name: "conventions/b: a Stream handle lowered into an IMPORT reaches the host", + ignore: !passReady, + fn: async () => { + await transcript("b-stream-handle-import-position", async (t) => { + let seen = "none"; + const c = await instantiateFixture(guest("stream-pass"), { + // The guest hands the host's own stream back through an import. A5: + // "host -> guest -> host pass-through works with the guest never + // reading; the payload then moves host<->host without touching guest + // memory." + sink: async (data: { read(n: number): Promise }) => { + seen = classify(data); + let total = 0n; + for (;;) { + const chunk = await data.read(8) as Uint8Array; + if (chunk.length === 0) break; + for (const b of chunk) total += BigInt(b); + } + return total; + }, + }); + await t.attempt("forward", () => c.exports.forward([5, 6, 7])); + t.note("sink-argument", { classified: seen }); + }); + }, +}); + +const futureImportReady = await haveFixture(guest("future-import")); + +Deno.test({ + name: "conventions/b: A12 — an import whose result is future returns the source", + ignore: !futureImportReady, + fn: async () => { + await transcript("b-a12-future-result-import", async (t) => { + // The load-bearing property: `next-value` is a SYNC WIT func. Its + // returned thenable is lowered as the future ITSELF — the import call + // completes immediately — not adopted as the call's async completion. + let settle: (v: number) => void = () => {}; + const c = await instantiateFixture(guest("future-import"), { + nextValue: () => new Promise((res) => (settle = res)), + sendSink: async (data: { read(n: number): Promise }) => { + // The tcp `send` shape: the guest writes `data` only AFTER this + // import returns, so adopting the thenable would be a livelock. + let n = 0; + for (;;) { + const chunk = await data.read(16); + if (chunk.length === 0) break; + n += chunk.length; + } + return n; + }, + recvPair: () => { + let done!: (v: number) => void; + const settled = new Promise((r) => (done = r)); + const source = (async function* () { + yield new Uint8Array([1, 2, 3]); + yield new Uint8Array([4]); + done(99); + })(); + return [source, settled]; + }, + }); + + const running = c.exports.runNext(); + // The import has already been entered AND RETURNED — the guest holds the + // future and is parked on it. Nothing here races: the guest cannot make + // progress until the producer settles, whatever the scheduler does. + settle(42); + await t.attempt("run-next", () => running); + + // The livelock probe: `run-send` writes the stream only after the sync + // import returned, so a reply at all is the A12 property. + await t.attempt("run-send", () => c.exports.runSend(4)); + // The tcp-receive shape: stream + future out of one sync import. + await t.attempt("run-recv", () => c.exports.runRecv()); + }); + }, +}); diff --git a/runtime/tests/conventions/probe.ts b/runtime/tests/conventions/probe.ts new file mode 100644 index 0000000..dfe0d5e --- /dev/null +++ b/runtime/tests/conventions/probe.ts @@ -0,0 +1,173 @@ +// The PROBE HOST MODULE — written exactly the way a consumer writes one +// (contracts/embedder-api.md §"The host-ABI surface and its version", A22: +// "Host modules MUST NOT import `@polyengine/runtime`"). +// +// Everything below reaches the engine through the boundary only. The single +// import is `@polyengine/protocol`, for VOCABULARY: the recognition predicates, +// `suspending()`, `ComponentException`. No runtime import, no class-identity +// check, no `instanceof` against an engine class — recognition is by brand +// everywhere (A9). +// +// `probe_zero_import.ts` is the same story with the import removed entirely: +// hand-rolled `Symbol.for` brands, which A9 declares legal values ("a +// hand-rolled object carrying the right brand IS a ComponentException to every +// copy"). If that file ever grows an import, the property it demonstrates is +// gone. + +import { + ComponentException, + type ErrorContext, + type Future, + isComponentException, + isDroppedError, + isErrorContext, + isFuture, + isInvalidHandleError, + isPeerTrappedError, + isStream, + isStreamProducerError, + isStreamWriter, + isTrap, + type Stream, + suspending, +} from "@polyengine/protocol"; + +export { ComponentException, isErrorContext, isFuture, isStream, suspending }; +export type { ErrorContext, Future, Stream }; + +/** + * What the protocol vocabulary says a value IS. Ordered most-specific first; + * the answer is a brand verdict, never a constructor name. + */ +export function classify(v: unknown): string { + if (isStream(v)) return "stream"; + if (isStreamWriter(v)) return "streamWriter"; + if (isFuture(v)) return "future"; + if (isErrorContext(v)) return "errorContext"; + if (isComponentException(v)) return "componentException"; + if (isPeerTrappedError(v)) return "peerTrapped"; + if (isDroppedError(v)) return "dropped"; + if (isInvalidHandleError(v)) return "invalidHandle"; + if (isStreamProducerError(v)) return "streamProducer"; + if (isTrap(v)) return "trap"; + if (v instanceof Error) return "unbranded-error"; + if (typeof v === "object" && v !== null && "then" in v) return "thenable"; + return typeof v; +} + +/** + * `PromiseLike` but deliberately NOT a Promise and NOT a `Future` handle — the + * "plain thenable" lowering source of §"Streams and futures". + */ +export function thenable(value: T): PromiseLike { + return { + then(onOk?: ((v: T) => R | PromiseLike) | null): PromiseLike { + return Promise.resolve().then(() => onOk!(value)); + }, + }; +} + +/** An `AsyncIterable` lowering source. */ +export async function* asyncIterable( + values: readonly T[], +): AsyncIterableIterator { + for (const v of values) yield v; +} + +/** A `ReadableStream` lowering source. */ +export function readable(values: readonly T[]): ReadableStream { + return new ReadableStream({ + start(c) { + for (const v of values) c.enqueue(v); + c.close(); + }, + }); +} + +// --------------------------------------------------------------------------- +// Host-implemented resources (§"Resources": "a plain class implementing the +// bindgen-emitted interface", the runtime owns the instance<->rep mapping) +// --------------------------------------------------------------------------- + +/** `host:api/res`'s `R`: the plainest host resource there is. */ +export class Cell { + static disposed: number[] = []; + static made: number[] = []; + constructor(readonly v: number) { + Cell.made.push(v); + } + [Symbol.dispose]() { + Cell.disposed.push(this.v); + } + static reset() { + Cell.disposed = []; + Cell.made = []; + } +} + +/** + * `host:api/dev`'s `gauge`: constructor + method + static, the full member + * surface. `calibrate` is a real `static`, so it exercises the static arm of + * the mangled-name assembly (`[static]gauge.calibrate`). + */ +export class Gauge { + static calibrations = 0; + static disposed: number[] = []; + constructor(readonly v: number) {} + read(): number { + // `this` is the instance — no reps, no side tables (§"Resources"). + return this.v; + } + static calibrate(): number { + return ++Gauge.calibrations; + } + [Symbol.dispose]() { + Gauge.disposed.push(this.v); + } + static reset() { + Gauge.calibrations = 0; + Gauge.disposed = []; + } +} + +/** + * The A2 suspending mark on a class PROTOTYPE method: the prototype is the + * per-declaration brand authority, read at wrap time, so every instance + * dispatched through it parks. + */ +export class SuspendingGauge { + static disposed: number[] = []; + constructor(readonly v: number) {} + read(): Promise { + return Promise.resolve(this.v); + } + static calibrate(): number { + return 7; + } + [Symbol.dispose]() { + SuspendingGauge.disposed.push(this.v); + } + static reset() { + SuspendingGauge.disposed = []; + } +} +// The direct-call spelling (`suspending(fn)`) applied to the prototype slot — +// the canonical form, and the only one available without decorators. +SuspendingGauge.prototype.read = suspending( + SuspendingGauge.prototype.read, +) as typeof SuspendingGauge.prototype.read; + +/** + * An interface provider that is a CLASS INSTANCE (A2: "interface members are + * invoked with their containing object as receiver"). `add` reads instance + * state, so a wrong receiver is a wrong answer rather than a silent pass. + */ +export class MathProvider { + constructor(readonly bias: number) {} + add(a: number, b: number): number { + return a + b + this.bias; + } + greet(who: string): string { + return `hello ${who}`; + } +} diff --git a/runtime/tests/conventions/probe_zero_import.ts b/runtime/tests/conventions/probe_zero_import.ts new file mode 100644 index 0000000..9c20d90 --- /dev/null +++ b/runtime/tests/conventions/probe_zero_import.ts @@ -0,0 +1,53 @@ +// The ZERO-IMPORT probe host module (contracts/embedder-api.md §"Module +// identity and @polyengine/protocol", amendment A9: "Brands are contract +// markers, not a security boundary. A hand-rolled object carrying the right +// brand is a legal value … This is what makes zero-import host modules +// possible"). +// +// This file MUST NOT import anything. Its whole content is the demonstration: +// the brand keys are `Symbol.for` registry symbols, so a host module that +// spells them out by hand agrees with every copy of the engine and of +// `@polyengine/protocol` without sharing a module with either. +// +// The keys are the A18/A19 spellings from the brand table in §"Module +// identity"; the generation suffix `/1` is part of the key. + +/** `polyengine.componentException/1` — carried by err-result values (A19). */ +export const COMPONENT_EXCEPTION_KEY = "polyengine.componentException/1"; +/** `polyengine.suspending/1` — carried by the marked function (A1/A2). */ +export const SUSPENDING_KEY = "polyengine.suspending/1"; +/** `polyengine.errorContext/1` — message-valued at lowering since A20. */ +export const ERROR_CONTEXT_KEY = "polyengine.errorContext/1"; + +/** + * An err value with no protocol import anywhere in its provenance. `payload` + * is the WIT err value (A10); `message` is diagnostic. + */ +export function handRolledException(payload: unknown, message: string): Error { + const e = new Error(message) as Error & { payload: unknown }; + (e as unknown as Record)[ + Symbol.for(COMPONENT_EXCEPTION_KEY) + ] = true; + e.payload = payload; + return e; +} + +/** A suspending-marked function with no protocol import (A1). */ +export function handRolledSuspending unknown>( + fn: F, +): F { + (fn as unknown as Record)[Symbol.for(SUSPENDING_KEY)] = true; + return fn; +} + +/** + * A branded string-`message` carrier: what A20 makes lowerable where the guest + * expects an `error-context`, by minting a FRESH local context — never "the + * same" one, since an error-context's state is exactly its message. + */ +export function handRolledErrorContext(message: string): { message: string } { + const c = { message }; + (c as unknown as Record)[Symbol.for(ERROR_CONTEXT_KEY)] = + true; + return c; +} diff --git a/runtime/tests/conventions/resources_test.ts b/runtime/tests/conventions/resources_test.ts new file mode 100644 index 0000000..2439f1e --- /dev/null +++ b/runtime/tests/conventions/resources_test.ts @@ -0,0 +1,116 @@ +// ROW (d) — HOST-IMPLEMENTED RESOURCES (contracts/embedder-api.md +// §"Resources"): "a resource is a class instance on both sides of the +// boundary". The host provides a PLAIN CLASS — the WIT constructor is the JS +// constructor, methods are camelCase members, statics are static members — and +// the runtime owns the instance↔rep mapping. Method `self` IS the instance: no +// reps, no side tables (the C0 findings this deletes). When the guest drops its +// last own handle the runtime calls `instance[Symbol.dispose]?.()`. +// +// The transcript's load-bearing content is the ORDER of host-observable +// effects: construct, method, static, dispose — and that dispose lands on the +// guest's drop, not at some later collection. + +import { haveFixture, instantiateFixture, testdata } from "./harness.ts"; +import { transcript } from "./support.ts"; +import { Cell, Gauge, MathProvider } from "./probe.ts"; + +const resReady = await haveFixture(testdata("imported-resource")); + +Deno.test({ + name: "conventions/d: a plain class IS the resource; own out, borrow in, dtor on drop", + ignore: !resReady, + fn: async () => { + await transcript("d-host-resource-plain-class", async (t) => { + Cell.reset(); + const events: string[] = []; + const c = await instantiateFixture(testdata("imported-resource"), { + "host:api/res": { + R: Cell, + // The host passes `own`: the runtime registers the instance and + // the guest owns its handle. + make: (v: number) => { + events.push(`make(${v})`); + return new Cell(v); + }, + // The host receives `borrow`: its OWN instance back — identity, + // not a rebuilt wrapper. `sameInstance` is the whole claim. + value: (r: Cell) => { + events.push(`value(self.v=${r.v}, isCell=${r instanceof Cell})`); + return r.v; + }, + }, + }); + + // `roundtrip` does make -> value(borrow) -> drop inside one guest task. + await t.attempt("roundtrip", () => c.exports.roundtrip(7)); + t.note("effects", { events, disposed: Cell.disposed }); + + // `make-and-keep` leaves the handle ALIVE in the guest: no dispose yet. + const h = await t.attempt("make-and-keep", () => + c.exports.makeAndKeep(9)) as number; + t.note("before-guest-drop", { disposed: Cell.disposed }); + // …and `drop-handle` runs the destructor, right there. + await t.attempt("drop-handle", () => c.exports.dropHandle(h)); + t.note("after-guest-drop", { disposed: Cell.disposed }); + }); + }, +}); + +const gaugeReady = await haveFixture( + "runtime/tests/embedder/suspending-method.wasm", +); +const GAUGE = "runtime/tests/embedder/suspending-method.wasm"; + +Deno.test({ + name: "conventions/d: constructor + method + static on one host class", + ignore: !gaugeReady, + fn: async () => { + await transcript("d-host-resource-members", async (t) => { + Gauge.reset(); + // ONE entry — the class — serves `[constructor]gauge`, + // `[method]gauge.read` and `[static]gauge.calibrate`. The mangled-name + // assembly is the runtime's obligation, never the embedder's. + const c = await instantiateFixture(GAUGE, { + "host:api/dev": { Gauge }, + }); + // `probe` constructs, reads through the method, then drops. + await t.attempt("probe", () => c.exports.probe(41)); + t.note("after-probe", { disposed: Gauge.disposed }); + // `calib` calls the STATIC — no instance involved. + await t.attempt("calib", () => c.exports.calib()); + await t.attempt("calib-again", () => c.exports.calib()); + t.note("statics", { calibrations: Gauge.calibrations }); + }); + }, +}); + +const importsReady = await haveFixture(testdata("imports")); + +Deno.test({ + name: "conventions/d: A2 — a class instance is a legal interface provider", + ignore: !importsReady, + fn: async () => { + await transcript("d-interface-provider-class", async (t) => { + // A2: "interface members are invoked with their containing object as + // receiver", matching the resource static arm. A provider whose methods + // read `this` is therefore a fully supported spelling — the failure mode + // this pins is a silent unbound call, which reads as a wrong answer. + const c = await instantiateFixture(testdata("imports"), { + log: () => {}, + "host:api/math": new MathProvider(5), + }); + await t.attempt("run", () => c.exports.run(1, 1)); + + // …and a plain object literal is the other spelling, unchanged. + const c2 = await instantiateFixture(testdata("imports"), { + log: () => {}, + "host:api/math": { + add: (a: number, b: number) => a + b, + greet: (who: string) => `hi ${who}`, + }, + }); + await t.attempt("run/object-literal", () => c2.exports.run(1, 1)); + await t.attempt("greetLen/object-literal", () => c2.exports.greetLen()); + }); + }, +}); diff --git a/runtime/tests/conventions/support.ts b/runtime/tests/conventions/support.ts new file mode 100644 index 0000000..3c0e931 --- /dev/null +++ b/runtime/tests/conventions/support.ts @@ -0,0 +1,328 @@ +// The lift/lower CONVENTIONS suite — the executable definition of the host ABI +// (contracts/embedder-api.md §"The host-ABI surface and its version", +// amendment A22). +// +// WHAT THIS SUITE IS. Every other suite under runtime/tests/ asserts a +// property. This one RECORDS what the engine does at the host boundary, as a +// normalized transcript, and compares it byte-for-byte against a committed +// golden under `runtime/tests/conventions/golden/`. The goldens are the +// artifact: they make a host-ABI change impossible to ship silently. +// +// - Modifying or deleting a committed golden asserts a host-ABI behavior +// change and requires `breaking/protocol` in the same PR (or, for a +// reviewed behavior-neutral correction of the suite itself, +// `conventions-fix`). Adding goldens is free. +// - `tools/version-guard/check.ts` (LOCKED_GOLDEN_DIR) enforces that at PR +// time and authoritatively at cut time. The directory path is therefore +// load-bearing; do not move it. +// +// UPDATING A GOLDEN. Deliberately not the default test task's permissions — +// `deno task test` cannot write into the repo. From `runtime/`: +// +// deno test --allow-read=..,/tmp --allow-write=/tmp,tests/conventions/golden \ +// --allow-env=POLYENGINE_SCHED_SEED,POLYENGINE_UPDATE_GOLDEN \ +// --allow-run tests/conventions/ +// +// with `POLYENGINE_UPDATE_GOLDEN=1` in the environment. Then read the diff: +// every changed line is a claim about the host ABI, and the PR needs the label +// to match. +// +// THE PROBE HOST MODULE. `probe.ts` is written the way a consumer writes a +// host module: `@polyengine/protocol` for vocabulary (predicates, brands, +// `suspending()`, `ComponentException`) and NOTHING from the runtime. One case +// family goes further and hand-rolls its brands with zero protocol imports +// (A9: "a hand-rolled object carrying the right brand is a legal value"). +// The HARNESS side below is the APPLICATION — instantiation, artifact +// resolution — so it legitimately uses `@polyengine/runtime/embedder`. +// +// DETERMINISM IS A HARD REQUIREMENT. `just sched-seeds` re-runs this suite +// under POLYENGINE_SCHED_SEED=1 and =4242; transcripts must be byte-identical +// there and under FIFO. The rules that buy that, enforced by construction: +// +// - one transcript per case, driven by a single guest task, awaited to +// completion — no cross-task interleaving is ever recorded; +// - no timings, no durations, no object identities, no absolute paths, no +// iteration order that the scheduler chooses (object keys are emitted +// SORTED; array order is program order); +// - values are normalized STRUCTURALLY — a handle is recognized by the +// protocol brand predicate, never by a constructor name, which would pin +// a class identity A9 removed from the contract in the first place. +// +// One deliberate exception to "record the message": a trap authored by the +// ENGINE (a raw `unreachable`) carries the JS engine's own wording, which +// differs per engine and is explicitly not API (§"Error model"). `normalize` +// records such traps by brand alone. Runtime-AUTHORED trap wording is stable +// by project choice and IS recorded, because "the message names the import" +// is a convention worth pinning. + +import { + isComponentException, + isDroppedError, + isErrorContext, + isFuture, + isInvalidHandleError, + isPeerTrappedError, + isStream, + isStreamProducerError, + isStreamWriter, + isTrap, +} from "@polyengine/protocol"; + +// --------------------------------------------------------------------------- +// Normalization +// --------------------------------------------------------------------------- + +/** Absolute paths and `file://` URLs are environment, never behavior. */ +export function scrubMessage(m: string): string { + return m + .replace(/file:\/\/[^\s)>;,]+/g, "") + .replace(/(?") + .replace(/\r?\n[\s\S]*$/, " …"); +} + +/** + * A trap whose wording the engine chose (`guest trapped:` provenance prefix, + * §"Error model") is recorded by brand alone: V8/SpiderMonkey/JSC each phrase + * `unreachable` differently and the runtime deliberately does not normalize + * them. + */ +function engineWorded(message: string): boolean { + return message.includes("guest trapped:"); +} + +type Norm = unknown; + +function normError(e: object, seen: Set): Norm { + const err = e as Error & { + payload?: unknown; + progress?: number; + cause?: unknown; + code?: unknown; + }; + const msg = typeof err.message === "string" ? scrubMessage(err.message) : ""; + const body: Record = {}; + + if (isComponentException(e)) { + body.tag = "componentException"; + body.message = msg; + // A10: `payload` is the WIT err value; a payloadless err's payload is + // `undefined` (the empty-side spelling of §"Error model"). + if ("payload" in err) body.payload = normalize(err.payload, seen); + } else if (isPeerTrappedError(e)) { + body.tag = "peerTrapped"; + body.message = msg; + if (typeof err.progress === "number") body.progress = err.progress; + body.cause = normalize(err.cause, seen); + } else if (isTrap(e)) { + body.tag = "trap"; + // See the header note: engine-worded traps record no text. + body.message = engineWorded(msg) ? "" : msg; + } else if (isDroppedError(e)) { + body.tag = "dropped"; + } else if (isInvalidHandleError(e)) { + body.tag = "invalidHandle"; + body.message = msg; + } else if (isStreamProducerError(e)) { + body.tag = "streamProducer"; + body.message = msg; + body.cause = normalize(err.cause, seen); + } else { + // An UNBRANDED error: the class of value the contract says never crosses + // as an err (§"Error model"). Name and message only — no stack. Its + // `cause` IS walked: A20's canonical chain is an unbranded poisoning + // failure whose own cause is the underlying `Trap`, and the trap at the + // bottom must stay recognizable. + body.tag = "error"; + body.name = err.name; + body.message = msg; + if (err.cause !== undefined) body.cause = normalize(err.cause, seen); + } + return { "@err": body }; +} + +/** + * Structural normalization of any host-visible value into JSON-able data. + * + * The distinctions this preserves are exactly the ones the contract makes: + * an ABSENT property vs. one present-and-`undefined` (the option/variant rule, + * §"Value mapping"), `Uint8Array` vs. `T[]` (the `Chunk` rule), `bigint` + * vs. `number` (u64/s64), and brand membership for every stateful value. + */ +export function normalize(v: unknown, seen: Set = new Set()): Norm { + if (v === undefined) return "@undefined"; + if (v === null) return "@null"; + switch (typeof v) { + case "boolean": + case "string": + return v; + case "number": + // -0 and NaN are not JSON round-trippable; spell them. + if (Number.isNaN(v)) return "@NaN"; + if (v === 0 && Object.is(v, -0)) return "@-0"; + if (!Number.isFinite(v)) return v > 0 ? "@Infinity" : "@-Infinity"; + return v; + case "bigint": + return { "@bigint": v.toString() }; + case "symbol": + return "@symbol"; + case "function": + return "@function"; + } + + const o = v as object; + if (seen.has(o)) return "@cycle"; + seen.add(o); + try { + // Stateful handles: brand first, ALWAYS — never `instanceof`, never + // `constructor.name` (A9 removed class identity from the contract). + if (isStream(o)) return "@stream"; + if (isStreamWriter(o)) return "@streamWriter"; + if (isFuture(o)) return "@future"; + if (isErrorContext(o)) { + return { "@errorContext": (o as { message: string }).message }; + } + if (o instanceof Error) return normError(o, seen); + + if (o instanceof Uint8Array) return { "@u8": Array.from(o) }; + if (ArrayBuffer.isView(o) || o instanceof ArrayBuffer) return "@binary"; + if (Array.isArray(o)) return o.map((e) => normalize(e, seen)); + + const proto = Object.getPrototypeOf(o); + if (proto !== Object.prototype && proto !== null) { + // A class instance the conventions do not define a shape for (a host + // resource instance, say). Callers that care record a projection of it + // instead; recording an identity here would be nondeterministic. + return "@object"; + } + const out: Record = {}; + for (const k of Object.keys(o).sort()) { + out[k] = normalize((o as Record)[k], seen); + } + return out; + } finally { + seen.delete(o); + } +} + +/** Deterministic JSON: object keys emitted in sorted order at every depth. */ +function stableStringify(v: unknown): string { + if (v === null) return "null"; + if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`; + if (typeof v === "object") { + const o = v as Record; + return `{${ + Object.keys(o).sort().map((k) => + `${JSON.stringify(k)}:${stableStringify(o[k])}` + ).join(",") + }}`; + } + return JSON.stringify(v) ?? "null"; +} + +// --------------------------------------------------------------------------- +// The recorder +// --------------------------------------------------------------------------- + +/** + * One transcript. `note` appends an event in PROGRAM order; nothing about + * wall-clock time, task interleaving, or identity may reach it. + */ +export class Transcript { + readonly #lines: string[] = []; + constructor(readonly name: string) {} + + /** Record `ev` with structurally normalized fields. */ + note(ev: string, data: Record = {}): void { + const row: Record = { ev }; + for (const k of Object.keys(data).sort()) row[k] = normalize(data[k]); + this.#lines.push(stableStringify(row)); + } + + /** Record the outcome of `f`: its value, or whatever it threw. */ + async attempt(ev: string, f: () => unknown): Promise { + try { + const value = await f(); + this.note(ev, { ok: true, value }); + return value; + } catch (e) { + this.note(ev, { ok: false, threw: e }); + return undefined; + } + } + + /** + * Record the outcome of `f` and hand back whatever it THREW (undefined when + * it did not throw) — for cases that go on to interrogate the failure. + */ + async caught(ev: string, f: () => unknown): Promise { + try { + this.note(ev, { ok: true, value: await f() }); + return undefined; + } catch (e) { + this.note(ev, { ok: false, threw: e }); + return e; + } + } + + text(): string { + return this.#lines.map((l) => l + "\n").join(""); + } +} + +// --------------------------------------------------------------------------- +// Golden comparison +// --------------------------------------------------------------------------- + +function envFlag(name: string): boolean { + // `deno task test` grants --allow-env=POLYENGINE_SCHED_SEED only; reading + // anything else throws rather than returning undefined. The suite must run + // unchanged under those permissions, so the read is guarded. + try { + return (Deno.env.get(name) ?? "") !== ""; + } catch { + return false; + } +} + +const UPDATING = envFlag("POLYENGINE_UPDATE_GOLDEN"); + +/** + * Compare a transcript against its committed golden — or rewrite it when + * POLYENGINE_UPDATE_GOLDEN is set (see this file's header for the exact + * command; the default test task has no write permission for the repo). + */ +export async function checkGolden(t: Transcript): Promise { + const url = new URL(`./golden/${t.name}.jsonl`, import.meta.url); + const got = t.text(); + if (UPDATING) { + await Deno.writeTextFile(url, got); + return; + } + let want: string; + try { + want = await Deno.readTextFile(url); + } catch { + throw new Error( + `conventions: no golden for "${t.name}". This transcript is NEW ` + + `coverage (free to add — see support.ts's header for the update ` + + `command). Recorded:\n${got}`, + ); + } + if (got === want) return; + throw new Error( + `conventions: transcript "${t.name}" diverged from its golden.\n` + + `A divergence is a HOST-ABI BEHAVIOR CHANGE unless the suite itself was ` + + `wrong (contracts/embedder-api.md A22).\n--- golden ---\n${want}` + + `--- recorded ---\n${got}`, + ); +} + +/** Record a case and compare it, in one call. */ +export async function transcript( + name: string, + body: (t: Transcript) => Promise, +): Promise { + const t = new Transcript(name); + await body(t); + await checkGolden(t); +} diff --git a/runtime/tests/conventions/suspending_test.ts b/runtime/tests/conventions/suspending_test.ts new file mode 100644 index 0000000..0f5d0a4 --- /dev/null +++ b/runtime/tests/conventions/suspending_test.ts @@ -0,0 +1,138 @@ +// ROW (f) — `suspending()` (contracts/embedder-api.md §"Functions and async", +// amendments A1/A2). +// +// A sync-typed WIT import is typed to return `T` synchronously. Returning a +// Promise from one parks the calling WASM FRAME, and that is a DECLARED +// capability: the function must be marked, per declaration. The marker is a +// brand (`polyengine.suspending/1`), so a hand-rolled mark with zero protocol +// imports is the same thing to the engine — and for instance methods the CLASS +// PROTOTYPE is the brand authority, read at wrap time, so one mark relays to +// every instance. +// +// The negative arm matters as much: an UNMARKED sync import that returns a +// Promise is refused, naming `suspending()`. Silent degradation is what the +// declaration exists to prevent. + +import { haveFixture, instantiateFixture, jspiSupported, testdata } from "./harness.ts"; +import { transcript } from "./support.ts"; +import { Gauge, SuspendingGauge, suspending } from "./probe.ts"; +import { handRolledSuspending } from "./probe_zero_import.ts"; + +/** + * A Promise that settles only after a real macrotask hop, so a park is a + * genuine suspension across the event loop rather than a microtask formality. + * The transcript records no timing — only that the value came back. + */ +function later(value: T): Promise { + return new Promise((r) => setTimeout(() => r(value), 0)); +} + +const importsReady = (await haveFixture(testdata("imports"))) && jspiSupported(); + +Deno.test({ + name: "conventions/f: a MARKED sync-typed import parks and resumes with the value", + ignore: !importsReady, + fn: async () => { + await transcript("f-suspending-plain-import", async (t) => { + const c = await instantiateFixture(testdata("imports"), { + log: () => {}, + "host:api/math": { + // The canonical spelling: the direct call, the only form available + // in a record literal. + add: suspending((a: number, b: number) => later(a + b)), + greet: (who: string) => `hello ${who}`, + }, + }); + await t.attempt("run", () => c.exports.run(2, 40)); + + // A marked import that returns SYNCHRONOUSLY stays on the value path — + // it pays the continuation hop, but the result is not a Promise. + const sync = await instantiateFixture(testdata("imports"), { + log: () => {}, + "host:api/math": { + add: suspending((a: number, b: number) => a + b), + greet: (who: string) => `hello ${who}`, + }, + }); + await t.attempt("run/marked-but-sync", () => sync.exports.run(2, 40)); + + // A9: the mark is a brand, so a hand-rolled one (zero protocol imports) + // is the same declaration. + const hand = await instantiateFixture(testdata("imports"), { + log: () => {}, + "host:api/math": { + add: handRolledSuspending((a: number, b: number) => later(a + b)), + greet: (who: string) => `hello ${who}`, + }, + }); + await t.attempt("run/hand-rolled-mark", () => hand.exports.run(2, 40)); + }); + }, +}); + +Deno.test({ + name: "conventions/f: an UNMARKED sync import returning a Promise is refused", + ignore: !importsReady, + fn: async () => { + await transcript("f-suspending-unmarked-refusal", async (t) => { + const c = await instantiateFixture(testdata("imports"), { + log: () => {}, + "host:api/math": { + // No mark. Parking here would be an undeclared capability. + add: (a: number, b: number) => later(a + b), + greet: (who: string) => `hello ${who}`, + }, + }); + await t.attempt("run", () => c.exports.run(2, 40)); + }); + }, +}); + +Deno.test({ + name: "conventions/f: an explicit jspi:false refuses a MARKED import's Promise", + ignore: !importsReady, + fn: async () => { + await transcript("f-suspending-jspi-false", async (t) => { + // "rides the engine floor: on a non-JSPI engine a marked import that + // returns a Promise is refused at the call site (NeedsJspi), never + // silently degraded." `jspi: false` is the same floor, forced. + const c = await instantiateFixture(testdata("imports"), { + log: () => {}, + "host:api/math": { + add: suspending((a: number, b: number) => later(a + b)), + greet: (who: string) => `hello ${who}`, + }, + }, { jspi: false }); + await t.attempt("run", () => c.exports.run(2, 40)); + }); + }, +}); + +const GAUGE = "runtime/tests/embedder/suspending-method.wasm"; +const gaugeReady = (await haveFixture(GAUGE)) && jspiSupported(); + +Deno.test({ + name: "conventions/f: A2 — a mark on the class PROTOTYPE relays to instances", + ignore: !gaugeReady, + fn: async () => { + await transcript("f-suspending-prototype-relay", async (t) => { + SuspendingGauge.reset(); + // The prototype is the per-declaration brand authority, read at wrap + // time. The guest-driven CONSTRUCTOR stays synchronous (C2) while the + // METHOD parks — the `[method]pollable.block` shape. + const c = await instantiateFixture(GAUGE, { + "host:api/dev": { Gauge: SuspendingGauge }, + }); + await t.attempt("probe", () => c.exports.probe(41)); + t.note("dtor", { disposed: SuspendingGauge.disposed }); + + // The unmarked sibling class, for contrast: a synchronous method on a + // stateful provider needs no mark at all. + Gauge.reset(); + const plain = await instantiateFixture(GAUGE, { + "host:api/dev": { Gauge }, + }); + await t.attempt("probe/unmarked-sync", () => plain.exports.probe(5)); + }); + }, +}); diff --git a/runtime/tests/embedder/cross_copy_test.ts b/runtime/tests/embedder/cross_copy_test.ts index c977d4e..01d1049 100644 --- a/runtime/tests/embedder/cross_copy_test.ts +++ b/runtime/tests/embedder/cross_copy_test.ts @@ -16,11 +16,10 @@ function assertTrue(cond: boolean, msg = ""): void { } import { copyCensus, - COPY_URL, registerRuntimeCopy, - RUNTIME_VERSION, runtimeCopies, -} from "../../src/embedder/mod.ts"; +} from "@polyengine/protocol"; +import { COPY_URL, RUNTIME_VERSION } from "../../src/embedder/mod.ts"; import { lowerFutureSource, lowerStreamSource } from "../../src/embedder/streams.ts"; import { initWrapper, takeRep, wrapperState } from "../../src/embedder/resources.ts"; import { GuestResource } from "../../src/embedder/mod.ts"; diff --git a/runtime/tests/embedder/direct_streams_test.ts b/runtime/tests/embedder/direct_streams_test.ts index ac884db..eeb4dd4 100644 --- a/runtime/tests/embedder/direct_streams_test.ts +++ b/runtime/tests/embedder/direct_streams_test.ts @@ -21,12 +21,9 @@ import { assertEq } from "../support/asserts.ts"; import { caught, guest, haveFixture, instantiateFixture } from "./support.ts"; -import { - type DirectDestination, - type DirectSource, - PeerTrappedError, - Stream, -} from "../../src/embedder/mod.ts"; +import type { DirectDestination, DirectSource } from "@polyengine/protocol"; +import { PeerTrappedError } from "@polyengine/protocol"; +import { Stream } from "../../src/embedder/streams.ts"; const FIXTURE = guest("stream-pass"); const ready = await haveFixture(FIXTURE); diff --git a/runtime/tests/embedder/future_result_test.ts b/runtime/tests/embedder/future_result_test.ts index fefbc1e..2ade045 100644 --- a/runtime/tests/embedder/future_result_test.ts +++ b/runtime/tests/embedder/future_result_test.ts @@ -13,7 +13,7 @@ import { assertEq } from "../support/asserts.ts"; import { guest, haveFixture, instantiateFixture } from "./support.ts"; -import { Stream } from "../../src/embedder/mod.ts"; +import { Stream } from "../../src/embedder/streams.ts"; const FIXTURE = guest("future-import"); const have = await haveFixture(FIXTURE); diff --git a/runtime/tests/embedder/host_imports_test.ts b/runtime/tests/embedder/host_imports_test.ts index 70162c1..33e4082 100644 --- a/runtime/tests/embedder/host_imports_test.ts +++ b/runtime/tests/embedder/host_imports_test.ts @@ -9,7 +9,7 @@ import { assertEq } from "../support/asserts.ts"; import { caught, haveFixture, instantiateFixture, testdata } from "./support.ts"; -import { Trap, ComponentException } from "../../src/embedder/mod.ts"; +import { ComponentException, Trap } from "@polyengine/protocol"; import { INTERNAL_HOST_REGISTRIES } from "../../src/embedder/instantiate.ts"; const ready = await haveFixture(testdata("imports")); diff --git a/runtime/tests/embedder/passthrough_test.ts b/runtime/tests/embedder/passthrough_test.ts index 87b8b3f..fd77433 100644 --- a/runtime/tests/embedder/passthrough_test.ts +++ b/runtime/tests/embedder/passthrough_test.ts @@ -13,7 +13,7 @@ import { assertEq } from "../support/asserts.ts"; import { artifactsOf, guest, haveFixture, instantiateFixture } from "./support.ts"; import type { ComponentValue, ValType } from "../../src/cabi/types.ts"; import { SharedFutureImpl } from "../../src/task/mod.ts"; -import { Future, Stream } from "../../src/embedder/mod.ts"; +import { Future, Stream } from "../../src/embedder/streams.ts"; import { hostFuture, hostFutureFor, diff --git a/runtime/tests/embedder/platform_class_test.ts b/runtime/tests/embedder/platform_class_test.ts index 007653d..31bcbe7 100644 --- a/runtime/tests/embedder/platform_class_test.ts +++ b/runtime/tests/embedder/platform_class_test.ts @@ -25,7 +25,7 @@ import { assertEq } from "../support/asserts.ts"; import { caught, haveFixture, instantiateFixture } from "./support.ts"; -import { ComponentException, isTrap, Trap } from "../../src/embedder/mod.ts"; +import { ComponentException, isTrap, Trap } from "@polyengine/protocol"; const FIXTURE = "runtime/tests/embedder/platform-class.wasm"; const ready = await haveFixture(FIXTURE); diff --git a/runtime/tests/embedder/realm_local_test.ts b/runtime/tests/embedder/realm_local_test.ts index 0e3b29a..ae22b10 100644 --- a/runtime/tests/embedder/realm_local_test.ts +++ b/runtime/tests/embedder/realm_local_test.ts @@ -14,7 +14,7 @@ import { Future, Stream, StreamWriter, -} from "../../src/embedder/mod.ts"; +} from "../../src/embedder/streams.ts"; import { fromHost } from "../../src/embedder/values.ts"; import { hostFuture } from "../../src/exec/mod.ts"; import type { ComponentValue, ValType } from "../../src/cabi/types.ts"; diff --git a/runtime/tests/embedder/resources_test.ts b/runtime/tests/embedder/resources_test.ts index 3a106bd..add35f9 100644 --- a/runtime/tests/embedder/resources_test.ts +++ b/runtime/tests/embedder/resources_test.ts @@ -8,7 +8,7 @@ import { assertEq } from "../support/asserts.ts"; import { caught, guest, haveFixture, instantiateFixture } from "./support.ts"; -import { InvalidHandleError } from "../../src/embedder/mod.ts"; +import { InvalidHandleError } from "@polyengine/protocol"; const ready = await haveFixture(guest("resources")); const IFACE = "polyengine:resources/counters"; diff --git a/runtime/tests/embedder/streams_test.ts b/runtime/tests/embedder/streams_test.ts index bd93eba..7949c9d 100644 --- a/runtime/tests/embedder/streams_test.ts +++ b/runtime/tests/embedder/streams_test.ts @@ -4,12 +4,11 @@ import { assertEq } from "../support/asserts.ts"; import { caught, guest, haveFixture, instantiateFixture } from "./support.ts"; +import { DroppedError, StreamProducerError } from "@polyengine/protocol"; import { - DroppedError, Future, Stream, - StreamProducerError, -} from "../../src/embedder/mod.ts"; +} from "../../src/embedder/streams.ts"; import { hostStream, hostStreamFor } from "../../src/exec/mod.ts"; import { LiftLowerContext, mkCanonicalOptions } from "../../src/cabi/context.ts"; import { Table } from "../../src/cabi/handles.ts"; diff --git a/runtime/tests/embedder/suspending_imports_test.ts b/runtime/tests/embedder/suspending_imports_test.ts index c114015..50c1312 100644 --- a/runtime/tests/embedder/suspending_imports_test.ts +++ b/runtime/tests/embedder/suspending_imports_test.ts @@ -25,7 +25,7 @@ import { readArtifact, testdata, } from "./support.ts"; -import { suspending } from "../../src/embedder/mod.ts"; +import { suspending } from "@polyengine/protocol"; import { anySuspendingImport, isSuspending, @@ -185,7 +185,7 @@ Deno.test({ // value — the parked frame resumes into `result::err` (run() == 1), and // nothing traps. The sync-throw variant of this pin lives in // host_imports_test.ts; this is the same rail at resume time. - const { ComponentException } = await import("../../src/embedder/mod.ts"); + const { ComponentException } = await import("@polyengine/protocol"); const c = await instantiateFixture( "runtime/tests/embedder/host-result.wasm", { diff --git a/runtime/tests/embedder/trap_retire_test.ts b/runtime/tests/embedder/trap_retire_test.ts index 0d11d20..0c55a05 100644 --- a/runtime/tests/embedder/trap_retire_test.ts +++ b/runtime/tests/embedder/trap_retire_test.ts @@ -16,7 +16,8 @@ import { assertEq } from "../support/asserts.ts"; import { caught, guest, haveFixture, instantiateFixture } from "./support.ts"; -import { PeerTrappedError, Stream, Trap } from "../../src/embedder/mod.ts"; +import { PeerTrappedError, Trap } from "@polyengine/protocol"; +import { Stream } from "../../src/embedder/streams.ts"; import { hostStream } from "../../src/exec/mod.ts"; const FIXTURE = guest("stream-pass"); diff --git a/runtime/tests/embedder/values_test.ts b/runtime/tests/embedder/values_test.ts index d0be64d..9a98961 100644 --- a/runtime/tests/embedder/values_test.ts +++ b/runtime/tests/embedder/values_test.ts @@ -8,7 +8,7 @@ import { assertEq } from "../support/asserts.ts"; import { caught, guest, haveFixture, instantiateFixture } from "./support.ts"; -import { ComponentException } from "../../src/embedder/mod.ts"; +import { ComponentException } from "@polyengine/protocol"; const ready = await haveFixture(guest("values")); diff --git a/runtime/tests/jspi/hop_atomicity_test.ts b/runtime/tests/jspi/hop_atomicity_test.ts index a48c6fb..52c57c5 100644 --- a/runtime/tests/jspi/hop_atomicity_test.ts +++ b/runtime/tests/jspi/hop_atomicity_test.ts @@ -36,7 +36,8 @@ // definitions.py line references for every encoding it relies on. import { assert, assertEquals } from "./asserts.ts"; import { Translator } from "../../src/shim/mod.ts"; -import { instantiate, suspending } from "../../src/embedder/mod.ts"; +import { instantiate } from "../../src/embedder/mod.ts"; +import { suspending } from "@polyengine/protocol"; import { planNeedsSuspension } from "../../src/jspi/bridge.ts"; import { isSupported } from "../../src/jspi/mechanics.ts"; diff --git a/tools/npm-build/consumer/check.mjs b/tools/npm-build/consumer/check.mjs index 6188469..f2d8860 100644 --- a/tools/npm-build/consumer/check.mjs +++ b/tools/npm-build/consumer/check.mjs @@ -36,10 +36,12 @@ assert.equal( ); assert.equal(protocol.PROTOCOL_GENERATION, 1, "unexpected brand generation"); -const thrown = new embedder.ComponentException({ kind: "smoke" }); +// `ComponentException` is host-ABI vocabulary (amendment A22): it lives on +// `@polyengine/protocol`, not the runtime's embedder module, since A22. +const thrown = new protocol.ComponentException({ kind: "smoke" }); assert.ok( protocol.isComponentException(thrown), - "a runtime-minted ComponentException is not recognized by the protocol package", + "a protocol-minted ComponentException is not recognized by its own predicate", ); assert.ok( thrown[Symbol.for("polyengine.componentException/1")], diff --git a/tools/npm-build/consumer/types.ts b/tools/npm-build/consumer/types.ts index 0de38bb..3a69903 100644 --- a/tools/npm-build/consumer/types.ts +++ b/tools/npm-build/consumer/types.ts @@ -6,15 +6,19 @@ // Nothing here runs. It exists to be type-checked. import { - ComponentException, type ComponentArtifacts, + createStream, instantiate, - isComponentException, - Stream, - suspending, } from "@polyengine/runtime/embedder"; import { Translator } from "@polyengine/runtime/shim"; -import { COMPONENT_EXCEPTION, copyCensus, PROTOCOL_GENERATION } from "@polyengine/protocol"; +import { + COMPONENT_EXCEPTION, + ComponentException, + copyCensus, + isComponentException, + PROTOCOL_GENERATION, + suspending, +} from "@polyengine/protocol"; import { defaultTranslator } from "@polyengine/translator"; import { wasi } from "@polyengine/wasi"; import { runSuite } from "@polyengine/ct-runner"; @@ -32,7 +36,10 @@ export async function typeSurface(componentBytes: Uint8Array) { // A byte stream is `Stream`: `Chunk` widens a numeric element // type to `Uint8Array | number[]`, so the u8 bulk path is expressible. - const { stream, writer } = Stream.create(); + // `createStream` (amendment A22) is the application-surface spelling of + // the former `Stream.create()` static — the concrete class is no longer + // exported. + const { stream, writer } = createStream(); await writer.write(new Uint8Array([1, 2, 3])); await writer.close(); diff --git a/tools/release-bundle/build.ts b/tools/release-bundle/build.ts index 4279309..9e911d1 100644 --- a/tools/release-bundle/build.ts +++ b/tools/release-bundle/build.ts @@ -14,7 +14,7 @@ const repoRoot = normalize( join(dirname(fromFileUrl(import.meta.url)), "..", ".."), ); -export async function buildBundle(out?: string): Promise { +export async function buildBundle(out?: string, entry?: string): Promise { const outPath = out ?? join(repoRoot, "tools", "release-bundle", "dist", "polyengine-embedder.mjs"); await Deno.mkdir(dirname(outPath), { recursive: true }); @@ -27,7 +27,7 @@ export async function buildBundle(out?: string): Promise { "esm", "-o", outPath, - join(repoRoot, "tools", "release-bundle", "entry.ts"), + entry ?? join(repoRoot, "tools", "release-bundle", "entry.ts"), ], cwd: repoRoot, stdout: "inherit", diff --git a/tools/release-bundle/dual_copy_test.ts b/tools/release-bundle/dual_copy_test.ts index 13cdbc1..2d680ab 100644 --- a/tools/release-bundle/dual_copy_test.ts +++ b/tools/release-bundle/dual_copy_test.ts @@ -20,17 +20,19 @@ // the boundary; the STATEFUL ones (`Stream`) are refused with a named // cross-copy error rather than silently adapted; and an unbranded throw in a // multi-copy graph says so. +// +// Amendment A22 shrinks the SHIPPED bundle (./entry.ts) to application +// surface only — it no longer re-exports `@polyengine/protocol` vocabulary, +// on purpose (contracts/embedder-api.md §"The host-ABI surface and its +// version"). This test builds copy B from ./test_entry.ts instead: the same +// dependency graph as the shipped entry, plus protocol re-exports so this +// test can still reach copy B's OWN classes — the premise the test pins. +// The production artifact (built from entry.ts) is unaffected. import { buildBundle } from "./build.ts"; -import { - copyCensus, - COPY_URL, - instantiate, - isSuspending, - runtimeCopies, - Stream, - ComponentException, -} from "../../runtime/src/embedder/mod.ts"; +import { COPY_URL, instantiate } from "../../runtime/src/embedder/mod.ts"; +import { ComponentException, copyCensus, isSuspending, runtimeCopies } from "@polyengine/protocol"; +import { Stream } from "../../runtime/src/embedder/streams.ts"; import { lowerStreamSource } from "../../runtime/src/embedder/streams.ts"; import { Translator } from "../../runtime/src/shim/mod.ts"; @@ -73,7 +75,10 @@ Deno.test({ name: "A9 dual-copy pin: source + bundle copies honor the brands and refuse foreign handles", ignore: !ready, fn: async () => { - const out = await buildBundle(); + const out = await buildBundle( + undefined, + new URL("./test_entry.ts", import.meta.url).pathname, + ); // deno-lint-ignore no-explicit-any const B: any = await import(new URL(`file://${out}`).href); @@ -126,7 +131,7 @@ Deno.test({ // ---- 4. a foreign STREAM handle is refused, loudly ----------------- // Stateful: its machinery lives in copy B. Without the brand check it // would fall through to producer adaptation and be pumped by value. - const { stream: foreignStream } = B.Stream.create(); + const { stream: foreignStream } = B.createStream(); assert( !(foreignStream instanceof Stream), "premise: copy B's Stream is not copy A's Stream", diff --git a/tools/release-bundle/test_entry.ts b/tools/release-bundle/test_entry.ts new file mode 100644 index 0000000..dddb0ff --- /dev/null +++ b/tools/release-bundle/test_entry.ts @@ -0,0 +1,20 @@ +// TEST-ONLY bundle entry — never shipped as the `polyengine-embedder.mjs` +// release asset (see ./entry.ts for that surface, and build.ts's +// `buildBundle(out, entry)` for the override this file relies on). +// +// dual_copy_test.ts (A9/A22) needs to reach copy B's OWN `@polyengine/protocol` +// instance — the class-per-copy premise the test pins — but amendment A22 +// (contracts/embedder-api.md §"The host-ABI surface and its version") makes +// the *shipped* entry stop re-exporting protocol vocabulary, on purpose: a +// real host module never gets it from the runtime. This second entry point +// exists solely so the in-repo cross-copy test can still observe copy B's +// protocol symbols after the bundle's own dependency graph resolves them — +// it changes nothing about what a consumer's bundle exports. +export * from "./entry.ts"; +export { + ComponentException, + isStream, + isSuspending, + runtimeCopies, + suspending, +} from "@polyengine/protocol"; diff --git a/tools/version-guard/check.ts b/tools/version-guard/check.ts index 729fb34..29df5d1 100644 --- a/tools/version-guard/check.ts +++ b/tools/version-guard/check.ts @@ -60,6 +60,13 @@ export const PACKAGES = [...LOCKSTEP, "protocol"]; const JSR_PROTOCOL = "https://jsr.io/@polyengine/protocol"; +/** The committed conventions-suite goldens (contracts/embedder-api.md + * "The conventions suite is the executable definition of the host ABI", + * amendment A22): the suite itself lands in a later track, so this + * directory does not exist yet in most trees — every check below must + * pass vacuously (no M/D found) when it is absent or untouched. */ +export const LOCKED_GOLDEN_DIR = "runtime/tests/conventions/golden/"; + export type Check = { name: string; ok: boolean; detail: string }; const pass = (name: string, detail: string): Check => ({ @@ -147,6 +154,74 @@ export async function latestCutVersion( return { tag, version: tag.slice(1) }; } +// ----- conventions goldens (A22) ----------------------------------------------- + +export type GoldenChange = { status: "A" | "M" | "D"; path: string }; + +/** Parse `git diff --name-status ... -- ` output. A rename is + * treated as an M of the old path plus an A of the new one (task authority: + * dispatch step 1) — the new content still needs the gate, but the OLD + * golden's disappearance is exactly what a plain M/D would flag, and a pure + * rename-with-no-content-change should not dodge that by virtue of the + * path move. A copy (`C...`) only introduces a new path, so it is an A. */ +export function parseGoldenNameStatus(output: string): GoldenChange[] { + const changes: GoldenChange[] = []; + for (const raw of output.split("\n")) { + const line = raw.trim(); + if (!line) continue; + const parts = line.split("\t"); + const code = parts[0]; + if (code.startsWith("R")) { + const [oldPath, newPath] = [parts[1], parts[2]]; + changes.push({ status: "M", path: oldPath }); + changes.push({ status: "A", path: newPath }); + } else if (code.startsWith("C")) { + changes.push({ status: "A", path: parts[2] ?? parts[1] }); + } else if (code === "A" || code === "M" || code === "D") { + changes.push({ status: code, path: parts[1] }); + } + // Other statuses (T, U, X, B) do not occur for plain committed text + // fixtures; ignoring them fails closed only in the sense that they + // neither trigger nor excuse the gate, which matches "added is free". + } + return changes; +} + +const ACCEPTED_GOLDEN_LABELS = ["breaking/protocol", "conventions-fix"]; + +/** PR-time (advisory) gate: a modified/deleted golden requires either + * `breaking/protocol` (the existing label/minor-bump machinery then + * enforces the protocol bump — not duplicated here) or `conventions-fix` + * (the reviewed behavior-neutral-correction escape). Added-only goldens + * never trigger. */ +export function conventionsGoldenPrCheck( + changes: GoldenChange[], + labels: string[], +): Check { + const touched = changes.filter((c) => c.status === "M" || c.status === "D"); + if (touched.length === 0) { + return pass( + "conventions-goldens", + `no modified/deleted goldens under ${LOCKED_GOLDEN_DIR}`, + ); + } + const excused = ACCEPTED_GOLDEN_LABELS.some((l) => labels.includes(l)); + if (excused) { + return pass( + "conventions-goldens", + `${touched.length} modified/deleted golden(s) (${ + touched.map((c) => c.path).join(", ") + }) excused by ${ACCEPTED_GOLDEN_LABELS.filter((l) => labels.includes(l)).join(", ")}`, + ); + } + return fail( + "conventions-goldens", + `this PR modifies or deletes committed goldens under ${LOCKED_GOLDEN_DIR} (${ + touched.map((c) => c.path).join(", ") + }) — that asserts a host-ABI behavior change (contracts/embedder-api.md A22) and requires either the breaking/protocol label (the protocol minor bump it implies) or, for a reviewed behavior-neutral correction of the suite itself, the conventions-fix label`, + ); +} + // ----- pr mode ---------------------------------------------------------------- export type PrEnv = { @@ -167,7 +242,13 @@ export type PrEnv = { export async function fetchBase( fx: Effects, baseSha: string, -): Promise<{ changed: string[]; baseRuntimeVersion: string | null }> { +): Promise< + { + changed: string[]; + baseRuntimeVersion: string | null; + goldenChanges: GoldenChange[]; + } +> { await fx.run("git", ["fetch", "origin", baseSha, "--depth=1"]); let diff = await fx.run("git", [ "diff", @@ -182,6 +263,31 @@ export async function fetchBase( `cannot diff against the PR base ${baseSha}:\n${diff.stderr.trim()}`, ); } + // Same base, same three-dot/two-dot fallback, scoped to the locked + // goldens dir and asking for rename/status detail instead of names only + // — the A22 gate needs to tell "added" from "modified/deleted". + let goldenDiff = await fx.run("git", [ + "diff", + "--name-status", + `${baseSha}...HEAD`, + "--", + LOCKED_GOLDEN_DIR, + ]); + if (goldenDiff.code !== 0) { + goldenDiff = await fx.run("git", [ + "diff", + "--name-status", + baseSha, + "HEAD", + "--", + LOCKED_GOLDEN_DIR, + ]); + } + if (goldenDiff.code !== 0) { + throw new Error( + `cannot diff the locked goldens against the PR base ${baseSha}:\n${goldenDiff.stderr.trim()}`, + ); + } const show = await fx.run("git", ["show", `${baseSha}:runtime/deno.json`]); const baseRuntimeVersion = show.code === 0 ? JSON.parse(show.stdout)?.version ?? null @@ -189,6 +295,7 @@ export async function fetchBase( return { changed: diff.stdout.split("\n").map((l) => l.trim()).filter(Boolean), baseRuntimeVersion, + goldenChanges: parseGoldenNameStatus(goldenDiff.stdout), }; } @@ -267,7 +374,10 @@ export async function prChecks(fx: Effects, env: PrEnv): Promise { // head), so it holds before the first cut too. A minor bump without a // label is either a missing label or an unintended bump; a PATCH bump // needs no label (that is the routine post-cut manifest-bump PR). - const { changed, baseRuntimeVersion } = await fetchBase(fx, env.baseSha); + const { changed, baseRuntimeVersion, goldenChanges } = await fetchBase( + fx, + env.baseSha, + ); if (baseRuntimeVersion === null) { checks.push(fail( "minor-bump-labelled", @@ -302,6 +412,13 @@ export async function prChecks(fx: Effects, env: PrEnv): Promise { changed, })); + // 8. A22: modifying/deleting a locked conventions golden asserts a + // host-ABI behavior change (contracts/embedder-api.md "The conventions + // suite is the executable definition of the host ABI"). Advisory here, + // same trust model as the breaking labels; authoritative gate is in cut + // mode below. + checks.push(conventionsGoldenPrCheck(goldenChanges, labels)); + return checks; } @@ -554,12 +671,52 @@ export async function protocolVersionAtRef( return JSON.parse(atob(content.replace(/\n/g, ""))).version; } +/** The locked-golden name-status diff for the whole release window, `git + * diff --name-status .. -- `. The release + * checkout is shallow (actions/checkout@v4 default depth), so the last + * cut's tag is fetched first — mirroring fetchBase's PR-base fetch — with + * the same three-dot-unavailable fallback (two-dot local comparison; here + * there is no merge-base ambiguity to begin with, so `..` is exact rather + * than a fallback in the same sense, but the two-call shape matches the + * rest of this file's style). */ +export async function cutGoldenChanges( + fx: Effects, + lastTag: string, + sha: string, +): Promise { + await fx.run("git", ["fetch", "origin", `refs/tags/${lastTag}`, "--depth=1"]); + let diff = await fx.run("git", [ + "diff", + "--name-status", + `${lastTag}..${sha}`, + "--", + LOCKED_GOLDEN_DIR, + ]); + if (diff.code !== 0) { + diff = await fx.run("git", [ + "diff", + "--name-status", + lastTag, + sha, + "--", + LOCKED_GOLDEN_DIR, + ]); + } + if (diff.code !== 0) { + throw new Error( + `cannot diff the locked goldens for ${lastTag}..${sha}:\n${diff.stderr.trim()}`, + ); + } + return parseGoldenNameStatus(diff.stdout); +} + export function cutGuards(input: { version: string; lastCutVersion: string; protocolVersion: string; protocolAtLastCut: string; window: ReleaseWindow; + goldenChanges: GoldenChange[]; }): Check[] { const checks: Check[] = []; const { version, lastCutVersion, window } = input; @@ -607,6 +764,49 @@ export function cutGuards(input: { )); } + // A22, authoritative: any M/D under the locked conventions goldens in + // this window asserts a host-ABI behavior change. The escape is either + // protocol on a LATER MINOR LINE than at the last cut (a behavior change + // is breaking by definition, so a patch move does not satisfy; a + // breaking/protocol PR forces the bump via cut-protocol-labels above, so + // this does not duplicate that enforcement — it catches the change that + // shipped with no label at all) or a conventions-fix label anywhere in + // the window (the reviewed behavior-neutral-correction escape, + // contracts/embedder-api.md A22). + const touchedGoldens = input.goldenChanges.filter((c) => + c.status === "M" || c.status === "D" + ); + if (touchedGoldens.length === 0) { + checks.push(pass( + "cut-conventions-goldens", + `no modified/deleted goldens under ${LOCKED_GOLDEN_DIR} in this window`, + )); + } else if (isMinorBumped(input.protocolVersion, input.protocolAtLastCut)) { + checks.push(pass( + "cut-conventions-goldens", + `${touchedGoldens.length} modified/deleted golden(s) (${ + touchedGoldens.map((c) => c.path).join(", ") + }); protocol ${input.protocolVersion} is a later minor line than the last cut's ${input.protocolAtLastCut}`, + )); + } else { + const excusedBy = window.prs.find((pr) => pr.labels.includes("conventions-fix")); + if (excusedBy) { + checks.push(pass( + "cut-conventions-goldens", + `${touchedGoldens.length} modified/deleted golden(s) (${ + touchedGoldens.map((c) => c.path).join(", ") + }) excused by conventions-fix on #${excusedBy.number}`, + )); + } else { + checks.push(fail( + "cut-conventions-goldens", + `this window modifies or deletes committed goldens under ${LOCKED_GOLDEN_DIR} (${ + touchedGoldens.map((c) => c.path).join(", ") + }) but protocol ${input.protocolVersion} is not a later minor line than the last cut's ${input.protocolAtLastCut}, and no PR in this window carries conventions-fix — a golden change asserts a host-ABI behavior change (contracts/embedder-api.md A22): label the PR breaking/protocol (and bump protocol's minor), or conventions-fix for a reviewed behavior-neutral correction`, + )); + } + } + return checks; } @@ -652,6 +852,7 @@ export async function cutChecks( protocolVersion: await readManifestVersion(fx, "protocol"), protocolAtLastCut: await protocolVersionAtRef(fx, input.repo, cut.tag), window, + goldenChanges: await cutGoldenChanges(fx, cut.tag, input.sha), }); if (input.out) await fx.writeFile(input.out, renderNotes(window)); checks.push(pass( diff --git a/tools/version-guard/check_test.ts b/tools/version-guard/check_test.ts index af5f606..66a4a59 100644 --- a/tools/version-guard/check_test.ts +++ b/tools/version-guard/check_test.ts @@ -191,6 +191,7 @@ function prFake(over: { baseRuntime?: string | null; changed?: string[]; published?: string; + goldenDiff?: string; }) { const lockstep = over.lockstep ?? "0.4.0"; const files: Record = { @@ -221,6 +222,8 @@ function prFake(over: { "diff --name-only base0000...HEAD": { stdout: (over.changed ?? ["runtime/src/x.ts"]).join("\n"), }, + "diff --name-status base0000...HEAD -- runtime/tests/conventions/golden/": + { stdout: over.goldenDiff ?? "" }, "show base0000:runtime/deno.json": over.baseRuntime === null ? { code: 1, stderr: "fatal: path does not exist" } : { stdout: manifest("runtime", over.baseRuntime ?? "0.4.0") }, @@ -507,6 +510,7 @@ Deno.test("cut: a breaking label in the window forces a minor bump", async () => protocolVersion: "0.2.1", protocolAtLastCut: "0.2.1", window, + goldenChanges: [], }); assertEquals(failed(bad), ["cut-lockstep-labels"]); assertStringIncludes(detail(bad, "cut-lockstep-labels"), "#219"); @@ -517,6 +521,7 @@ Deno.test("cut: a breaking label in the window forces a minor bump", async () => protocolVersion: "0.2.1", protocolAtLastCut: "0.2.1", window, + goldenChanges: [], }); assertEquals(failed(good), []); }); @@ -529,6 +534,7 @@ Deno.test("cut: breaking/protocol is judged against protocol at the last cut", a protocolVersion: "0.2.1", protocolAtLastCut: "0.2.0", window, + goldenChanges: [], }); assertEquals(failed(bad), ["cut-protocol-labels"]); const good = cutGuards({ @@ -537,6 +543,7 @@ Deno.test("cut: breaking/protocol is judged against protocol at the last cut", a protocolVersion: "0.3.0", protocolAtLastCut: "0.2.0", window, + goldenChanges: [], }); assertEquals(failed(good), []); }); @@ -609,6 +616,11 @@ Deno.test("cut: end to end — window scan, guards, and the notes fragment", asy content: btoa(manifest("protocol", "0.2.1")), }), }, + git: { + "fetch origin refs/tags/v0.4.0 --depth=1": {}, + "diff --name-status v0.4.0..cut12345 -- runtime/tests/conventions/golden/": + { stdout: "" }, + }, }); assertEquals(await main(fx, ["cut", "--out", "changes.md"]), 0); assertEquals(fx.written["changes.md"].split("\n"), [ @@ -664,3 +676,191 @@ Deno.test("cut: the first cut ever has no window", async () => { assertEquals(failed(checks), []); assertEquals(fx.written["changes.md"], ""); }); + +// ----- A22: conventions goldens (contracts/embedder-api.md "The +// conventions suite is the executable definition of the host ABI") ------------- + +const GOLDEN_DIR = "runtime/tests/conventions/golden/"; +const goldenNameStatus = (lines: string[]) => lines.join("\n"); + +Deno.test("pr: a modified golden with no excusing label fails", async () => { + const checks = await prChecks( + prFake({ goldenDiff: goldenNameStatus([`M\t${GOLDEN_DIR}lift-record.json`]) }), + PR_ENV, + ); + assertEquals(failed(checks), ["conventions-goldens"]); + const d = detail(checks, "conventions-goldens"); + assertStringIncludes(d, `${GOLDEN_DIR}lift-record.json`); + assertStringIncludes(d, "breaking/protocol"); + assertStringIncludes(d, "conventions-fix"); +}); + +Deno.test("pr: a modified golden with breaking/protocol passes (and does not double-fire with the label/bump checks)", async () => { + const checks = await prChecks( + prFake({ + goldenDiff: goldenNameStatus([`M\t${GOLDEN_DIR}error-model.json`]), + protocol: "0.3.0", + published: "0.2.1", + labels: ["breaking/protocol"], + changed: ["protocol/src/mod.ts", "protocol/deno.json"], + }), + PR_ENV, + ); + assertEquals(failed(checks), []); + assertStringIncludes( + detail(checks, "conventions-goldens"), + "excused by breaking/protocol", + ); +}); + +Deno.test("pr: a modified golden with conventions-fix passes without a version bump", async () => { + const checks = await prChecks( + prFake({ + goldenDiff: goldenNameStatus([`D\t${GOLDEN_DIR}stale-probe.json`]), + labels: ["conventions-fix"], + }), + PR_ENV, + ); + assertEquals(failed(checks), []); + assertStringIncludes( + detail(checks, "conventions-goldens"), + "excused by conventions-fix", + ); +}); + +Deno.test("pr: adding goldens only is free — no label required", async () => { + const checks = await prChecks( + prFake({ goldenDiff: goldenNameStatus([`A\t${GOLDEN_DIR}new-probe.json`]) }), + PR_ENV, + ); + assertEquals(failed(checks), []); + assertStringIncludes( + detail(checks, "conventions-goldens"), + "no modified/deleted goldens", + ); +}); + +Deno.test("pr: an absent/untouched locked dir passes vacuously", async () => { + const checks = await prChecks(prFake({}), PR_ENV); + assertEquals(failed(checks), []); + assertStringIncludes( + detail(checks, "conventions-goldens"), + "no modified/deleted goldens", + ); +}); + +Deno.test("pr: a rename is an M of the old path plus an A of the new — the old path still gates", async () => { + const checks = await prChecks( + prFake({ + goldenDiff: goldenNameStatus([ + `R100\t${GOLDEN_DIR}old-name.json\t${GOLDEN_DIR}new-name.json`, + ]), + }), + PR_ENV, + ); + assertEquals(failed(checks), ["conventions-goldens"]); + assertStringIncludes( + detail(checks, "conventions-goldens"), + `${GOLDEN_DIR}old-name.json`, + ); +}); + +function cutFake(over: { + goldenDiff?: string; + protocolVersion?: string; + protocolAtLastCut?: string; + windowLabels?: string[]; +}) { + const R = "polymorph-components/polyengine"; + return { R, fx: fake({ + files: { "protocol/deno.json": manifest("protocol", over.protocolVersion ?? "0.2.1") }, + env: { GITHUB_REPOSITORY: R, GITHUB_SHA: "cut12345", VERSION: "0.5.0" }, + gh: { + [`api repos/${R}/releases/latest`]: ghJson({ tag_name: "v0.4.0" }), + [`api repos/${R}/compare/v0.4.0...cut12345`]: ghJson({ + total_commits: 1, + commits: [{ sha: "aaa1111", commit: { message: "Merge PR #300\n" } }], + }), + [`api repos/${R}/commits/aaa1111/pulls`]: ghJson([ + { number: 300, title: "conventions tweak", labels: (over.windowLabels ?? []).map((name) => ({ name })) }, + ]), + [`api repos/${R}/contents/protocol/deno.json?ref=v0.4.0`]: ghJson({ + content: btoa(manifest("protocol", over.protocolAtLastCut ?? "0.2.1")), + }), + }, + git: { + "fetch origin refs/tags/v0.4.0 --depth=1": {}, + "diff --name-status v0.4.0..cut12345 -- runtime/tests/conventions/golden/": + { stdout: over.goldenDiff ?? "" }, + }, + }) }; +} + +Deno.test("cut: a modified golden in the window with no protocol bump and no conventions-fix fails", async () => { + const { fx } = cutFake({ + goldenDiff: goldenNameStatus([`M\t${GOLDEN_DIR}error-model.json`]), + }); + const checks = await cutChecks(fx, { + repo: "polymorph-components/polyengine", + sha: "cut12345", + version: "0.5.0", + out: null, + }); + assertEquals(failed(checks), ["cut-conventions-goldens"]); + assertStringIncludes( + detail(checks, "cut-conventions-goldens"), + `${GOLDEN_DIR}error-model.json`, + ); +}); + +Deno.test("cut: a modified golden in the window with protocol bumped past the last cut passes", async () => { + const { fx } = cutFake({ + goldenDiff: goldenNameStatus([`M\t${GOLDEN_DIR}error-model.json`]), + protocolVersion: "0.3.0", + protocolAtLastCut: "0.2.1", + }); + const checks = await cutChecks(fx, { + repo: "polymorph-components/polyengine", + sha: "cut12345", + version: "0.5.0", + out: null, + }); + assertEquals(failed(checks), []); +}); + +Deno.test("cut: a modified golden with only a protocol PATCH move (no minor bump, no conventions-fix) fails — a behavior change is breaking by definition", async () => { + const { fx } = cutFake({ + goldenDiff: goldenNameStatus([`M\t${GOLDEN_DIR}error-model.json`]), + protocolVersion: "0.2.2", + protocolAtLastCut: "0.2.1", + }); + const checks = await cutChecks(fx, { + repo: "polymorph-components/polyengine", + sha: "cut12345", + version: "0.5.0", + out: null, + }); + assertEquals(failed(checks), ["cut-conventions-goldens"]); + assertStringIncludes( + detail(checks, "cut-conventions-goldens"), + "not a later minor line", + ); +}); + +Deno.test("cut: a modified golden in the window excused by conventions-fix on a window PR passes without a protocol bump", async () => { + const { fx } = cutFake({ + goldenDiff: goldenNameStatus([`D\t${GOLDEN_DIR}stale-probe.json`]), + windowLabels: ["conventions-fix"], + }); + const checks = await cutChecks(fx, { + repo: "polymorph-components/polyengine", + sha: "cut12345", + version: "0.5.0", + out: null, + }); + assertEquals(failed(checks), []); + assertStringIncludes( + detail(checks, "cut-conventions-goldens"), + "excused by conventions-fix on #300", + ); +}); diff --git a/translator/deno.json b/translator/deno.json index 8dc7fd3..4af304e 100644 --- a/translator/deno.json +++ b/translator/deno.json @@ -1,6 +1,6 @@ { "name": "@polyengine/translator", - "version": "0.4.1", + "version": "0.5.0", "exports": { ".": "./mod.ts" }, diff --git a/wasi/deno.json b/wasi/deno.json index 7399f3c..8b94d44 100644 --- a/wasi/deno.json +++ b/wasi/deno.json @@ -1,6 +1,6 @@ { "name": "@polyengine/wasi", - "version": "0.4.1", + "version": "0.5.0", "exports": { ".": "./src/mod.ts", "./cli": "./src/cli.ts", diff --git a/wasi/src/cli_stdio.ts b/wasi/src/cli_stdio.ts index ff379ae..188dc66 100644 --- a/wasi/src/cli_stdio.ts +++ b/wasi/src/cli_stdio.ts @@ -47,7 +47,7 @@ // * terminals: reported from the real streams' `isTTY` (injectable). // * environment/arguments/cwd: the host process's, overridable. -import { Stream } from "@polyengine/runtime/embedder"; +import { isStream } from "@polyengine/protocol"; import { type CliByteSource, type CliErrorCode, @@ -176,7 +176,7 @@ export function cliStdio(options: CliStdioOptions = {}): CliStdio { } return OK; } catch (e) { - if (data instanceof Stream) data.drop(); // the guest's writer must not hang + if (isStream(data)) data.drop(); // the guest's writer must not hang return { kind: "err", value: ioErrorCode(e) }; } }; diff --git a/wasi/src/http.ts b/wasi/src/http.ts index 4fa2f9e..bf7ba1e 100644 --- a/wasi/src/http.ts +++ b/wasi/src/http.ts @@ -66,7 +66,7 @@ // Fetch failures are TypeErrors with prose; a small sniff table maps the // recognizable ones and everything else is `internal-error(message)`. -import { ComponentException, isComponentException, Stream } from "@polyengine/runtime/embedder"; +import { ComponentException, isComponentException, type Stream } from "@polyengine/protocol"; /** * The compatibility track the fragment registers on by default. diff --git a/wasi/src/internal/cli_shared.ts b/wasi/src/internal/cli_shared.ts index e4e1503..709b836 100644 --- a/wasi/src/internal/cli_shared.ts +++ b/wasi/src/internal/cli_shared.ts @@ -5,7 +5,7 @@ // vocabulary, never its sibling. import { defineBrand, WASI_EXIT } from "@polyengine/protocol"; -import { Stream } from "@polyengine/runtime/embedder"; +import type { Stream } from "@polyengine/protocol"; /** `wasi:cli/types@0.3`'s `error-code` ENUM: bare kebab-case strings (the * A10 value table — enums are data strings, not `{kind}` variants; this diff --git a/wasi/src/internal/fs_provider.ts b/wasi/src/internal/fs_provider.ts index 9778413..7e6766d 100644 --- a/wasi/src/internal/fs_provider.ts +++ b/wasi/src/internal/fs_provider.ts @@ -85,7 +85,7 @@ // pre-existing escaping symlinks alike are refused with `not-permitted`; // OPFS has no symlinks, so the web backend is immune by construction. -import { ComponentException, Stream, suspending } from "@polyengine/runtime/embedder"; +import { ComponentException, isStream, suspending, type Stream } from "@polyengine/protocol"; import { FedInputStream, IoError, OutputStream, Pollable, SinkOutputStream } from "../io.ts"; /** `wasi:filesystem/types.error-code` labels. 0.2 (enum): all of these, @@ -1075,7 +1075,7 @@ export function makeFilesystem( } return OK03; } catch (e) { - if (data instanceof Stream) data.drop(); // the guest's writer must not hang + if (isStream(data)) data.drop(); // the guest's writer must not hang return { kind: "err", value: { kind: e instanceof ComponentException ? (e.payload as { kind: FsErrorCode }).kind : map(e) }, @@ -1094,7 +1094,7 @@ export function makeFilesystem( } return OK03; } catch (e) { - if (data instanceof Stream) data.drop(); + if (isStream(data)) data.drop(); return { kind: "err", value: { kind: e instanceof ComponentException ? (e.payload as { kind: FsErrorCode }).kind : map(e) }, diff --git a/wasi/src/internal/sockets_02.ts b/wasi/src/internal/sockets_02.ts index 2df7282..296875a 100644 --- a/wasi/src/internal/sockets_02.ts +++ b/wasi/src/internal/sockets_02.ts @@ -48,7 +48,7 @@ // hop-limit/buffer-sizes are real where node has API, cached-getter // where it has only a setter, `not-supported` where it has neither. -import { ComponentException } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; import { FedInputStream, IoError, Pollable, SinkOutputStream } from "../io.ts"; import { type DatagramConn, diff --git a/wasi/src/internal/sockets_03.ts b/wasi/src/internal/sockets_03.ts index f24940a..6d48d45 100644 --- a/wasi/src/internal/sockets_03.ts +++ b/wasi/src/internal/sockets_03.ts @@ -4,7 +4,7 @@ // alongside the poll-shaped `@0.2` track (sockets_02.ts). Vocabulary // (codec, validation, error mapping, WIT types): sockets_shared.ts. -import { ComponentException, Stream, suspending } from "@polyengine/runtime/embedder"; +import { ComponentException, isStream, suspending } from "@polyengine/protocol"; import { type DatagramConn, dnsLookup, @@ -1122,5 +1122,5 @@ const TRANSIENT_ACCEPT_FAILURES: ReadonlySet = new Set( * iteration protocol itself (`for await`'s abrupt-exit `return()`). */ function dropSendSource(data: TcpSendSource): void { - if (data instanceof Stream) data.drop(); + if (isStream(data)) data.drop(); } diff --git a/wasi/src/internal/sockets_shared.ts b/wasi/src/internal/sockets_shared.ts index 630f643..464392d 100644 --- a/wasi/src/internal/sockets_shared.ts +++ b/wasi/src/internal/sockets_shared.ts @@ -3,7 +3,7 @@ // of these names is `@polyengine/wasi/sockets`. Address codec, wasmtime-parity // validation, platform error mapping, and the WIT-facing type shapes. -import { ComponentException, Stream } from "@polyengine/runtime/embedder"; +import { ComponentException, type Stream } from "@polyengine/protocol"; import type { NetAddr } from "./sockets_platform.ts"; export type { NetAddr }; diff --git a/wasi/src/io.ts b/wasi/src/io.ts index 0d53896..2e75652 100644 --- a/wasi/src/io.ts +++ b/wasi/src/io.ts @@ -47,7 +47,7 @@ // through the resource types registered here. import { defineBrand, defineRealmLocal, POLLABLE } from "@polyengine/protocol"; -import { suspending, ComponentException } from "@polyengine/runtime/embedder"; +import { suspending, ComponentException } from "@polyengine/protocol"; /** The engine setTimeout ceiling: delays above 2^31-1 ms are clamped to * ~0 (node/Deno warn and fire at 1 ms). `Pollable.timer` sleeps in diff --git a/wasi/src/mod.ts b/wasi/src/mod.ts index 1fadd9d..8358f35 100644 --- a/wasi/src/mod.ts +++ b/wasi/src/mod.ts @@ -1,6 +1,7 @@ // `@polyengine/wasi` — the WASI providers for polyengine hosts, and the // executable check that the embedder conventions -// (`@polyengine/runtime/embedder`) serve WASI (contracts/embedder-api.md C2 +// (`@polyengine/protocol`, amendment A22 — this package is protocol-only) +// serve WASI (contracts/embedder-api.md C2 // checklist item 7; docs/architecture.md §2 keeps implementations out of // the RUNTIME — this package is where they live). Scope: p2 // baseline + p3 clocks + à la carte sockets on BOTH tracks (the diff --git a/wasi/tests/cli_stdio_test.ts b/wasi/tests/cli_stdio_test.ts index 49c567f..47e4d6f 100644 --- a/wasi/tests/cli_stdio_test.ts +++ b/wasi/tests/cli_stdio_test.ts @@ -5,7 +5,7 @@ // rides on (A14: the blocking declarations are marked on io.ts's // REGISTERED prototypes; these duck-typed impls override behavior only). -import { ComponentException, isSuspending } from "@polyengine/runtime/embedder"; +import { ComponentException, isSuspending } from "@polyengine/protocol"; import { cliStdio } from "../src/cli_stdio.ts"; import { type CliIoResult, ExitError } from "../src/cli.ts"; import { diff --git a/wasi/tests/cli_test.ts b/wasi/tests/cli_test.ts index d5eae9d..e59462f 100644 --- a/wasi/tests/cli_test.ts +++ b/wasi/tests/cli_test.ts @@ -3,7 +3,7 @@ // recording"). import { assertEq, assertThrows, assertTrue } from "./asserts.ts"; -import { ComponentException } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; import { cli, ExitError } from "../src/cli.ts"; import type { StreamErrorValue } from "../src/io.ts"; diff --git a/wasi/tests/fs_node_test.ts b/wasi/tests/fs_node_test.ts index d917d34..e0fc459 100644 --- a/wasi/tests/fs_node_test.ts +++ b/wasi/tests/fs_node_test.ts @@ -10,7 +10,7 @@ // * ERROR SHAPES: 0.2 err payloads are BARE enum strings ("no-entry"); // 0.3 payloads are variant records ({ kind: "no-entry" }). -import { ComponentException } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; import { filesystemNode } from "../src/filesystem_node.ts"; import { FsIoError } from "../src/internal/fs_provider.ts"; import { assertEq, assertThrows, assertTrue } from "./asserts.ts"; diff --git a/wasi/tests/fs_readonly_test.ts b/wasi/tests/fs_readonly_test.ts index d4500be..27722d1 100644 --- a/wasi/tests/fs_readonly_test.ts +++ b/wasi/tests/fs_readonly_test.ts @@ -28,7 +28,7 @@ // making new ones, so refusing them would be meaningless on a filesystem // that never accepted a write. -import { ComponentException } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; import { filesystemNode } from "../src/filesystem_node.ts"; import { filesystemWeb } from "../src/filesystem_web.ts"; import { FakeDirectoryHandle } from "./support/opfs_fake.ts"; diff --git a/wasi/tests/fs_web_test.ts b/wasi/tests/fs_web_test.ts index 1a5fcea..0f58e24 100644 --- a/wasi/tests/fs_web_test.ts +++ b/wasi/tests/fs_web_test.ts @@ -14,7 +14,7 @@ // without `move()` falls back to copy+delete for files and fails // `unsupported` for directories. -import { ComponentException, isSuspending } from "@polyengine/runtime/embedder"; +import { ComponentException, isSuspending } from "@polyengine/protocol"; import { filesystemWeb } from "../src/filesystem_web.ts"; import { FakeDirectoryHandle } from "./support/opfs_fake.ts"; import { assertEq, assertRejects, assertTrue } from "./asserts.ts"; diff --git a/wasi/tests/http_test.ts b/wasi/tests/http_test.ts index 3dafea2..e3a5be1 100644 --- a/wasi/tests/http_test.ts +++ b/wasi/tests/http_test.ts @@ -8,7 +8,7 @@ // case names are the WIT spellings VERBATIM (`DNS-timeout`, // `internal-error` — capitals included). -import { ComponentException } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; import { HTTP_TRACK, type ErrorCode, diff --git a/wasi/tests/integration_exec_model_test.ts b/wasi/tests/integration_exec_model_test.ts index 022f7e0..faddea1 100644 --- a/wasi/tests/integration_exec_model_test.ts +++ b/wasi/tests/integration_exec_model_test.ts @@ -9,7 +9,8 @@ import { assertEq, assertTrue } from "./asserts.ts"; import { Translator } from "@polyengine/runtime/shim"; -import { instantiate, Stream } from "@polyengine/runtime/embedder"; +import { instantiate } from "@polyengine/runtime/embedder"; +import { Stream } from "@polyengine/protocol"; import { wasi } from "../src/mod.ts"; const ARTIFACT = diff --git a/wasi/tests/integration_sockets_test.ts b/wasi/tests/integration_sockets_test.ts index 1fe2ecd..b2226f0 100644 --- a/wasi/tests/integration_sockets_test.ts +++ b/wasi/tests/integration_sockets_test.ts @@ -25,7 +25,8 @@ import { assertEq, assertTrue } from "./asserts.ts"; import { Translator } from "@polyengine/runtime/shim"; -import { type Future, instantiate } from "@polyengine/runtime/embedder"; +import { instantiate } from "@polyengine/runtime/embedder"; +import type { Future } from "@polyengine/protocol"; import { sockets } from "../src/sockets.ts"; const FIXTURE = new URL( diff --git a/wasi/tests/io_test.ts b/wasi/tests/io_test.ts index 3c510b6..6a1ca21 100644 --- a/wasi/tests/io_test.ts +++ b/wasi/tests/io_test.ts @@ -2,7 +2,7 @@ // stream-error cases (contracts/embedder-api.md §"WASI examination"). import { assertEq, assertRejects, assertTrue } from "./asserts.ts"; -import { ComponentException } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; import { InputStream, io, OutputStream, Pollable, poll } from "../src/io.ts"; import type { StreamErrorValue } from "../src/io.ts"; diff --git a/wasi/tests/sockets_02_test.ts b/wasi/tests/sockets_02_test.ts index fbb9d3b..5aeee0f 100644 --- a/wasi/tests/sockets_02_test.ts +++ b/wasi/tests/sockets_02_test.ts @@ -4,7 +4,7 @@ // 0.3's variant — the A10 rule the composed gate can't isolate). The // happy composed path is integration_net_test.ts's std::net battery. -import { ComponentException } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; import type { Pollable } from "../src/io.ts"; import { type IpSocketAddress, SocketIoError, sockets } from "../src/sockets.ts"; import { assertEq, assertThrows, assertTrue } from "./asserts.ts"; diff --git a/wasi/tests/sockets_options_test.ts b/wasi/tests/sockets_options_test.ts index e2c5dc6..98620b2 100644 --- a/wasi/tests/sockets_options_test.ts +++ b/wasi/tests/sockets_options_test.ts @@ -5,7 +5,7 @@ // Same conventions as sockets_test.ts: real loopback sockets; every // guest-visible failure must be a BRANDED ComponentException. -import { ComponentException } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; import { type IpAddress, type IpSocketAddress, diff --git a/wasi/tests/sockets_tcp_test.ts b/wasi/tests/sockets_tcp_test.ts index e1c4f5d..2a6196f 100644 --- a/wasi/tests/sockets_tcp_test.ts +++ b/wasi/tests/sockets_tcp_test.ts @@ -10,7 +10,7 @@ // throws must be BRANDED ComponentExceptions (a bare throw would be a // guest trap). -import { ComponentException } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; import { type IpSocketAddress, type SocketErrorCode, diff --git a/wasi/tests/sockets_test.ts b/wasi/tests/sockets_test.ts index b7a7588..aeff71a 100644 --- a/wasi/tests/sockets_test.ts +++ b/wasi/tests/sockets_test.ts @@ -12,7 +12,7 @@ // `--allow-net --unstable-net`; without `--unstable-net` the provider // (correctly) answers `not-supported` and everything past `create` fails. -import { ComponentException } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; import { ipHostname, type IpSocketAddress,