diff --git a/contracts/plan-format.md b/contracts/plan-format.md index 87ccfa5..955e645 100644 --- a/contracts/plan-format.md +++ b/contracts/plan-format.md @@ -322,6 +322,32 @@ nondeterminism as a bug. ## Open items +- **Declared-import checking** ([#372](https://github.com/polymorph-components/polyengine/issues/372)): + `imports` contains runtime-used leaves from environ's `component.imports`, + not the complete `component.import_types` surface. Unused imports and + equality-bound resource aliases can disappear, so the current executor cannot + check their presence, kind, or supplied resource identity. This is a linking + gap, not evidence that those declarations impose no constraints. + + The next design must retain the declared import tree and its type constraints + while keeping runtime import indices stable for initializer references. + Resource aliases must refer to the existing resource identity, not allocate a + second token. Validate supplied aliases against that identity before running + initializers. Under the Wasmtime-compatible host policy, an omitted + equality-bound alias or recursively empty instance can be synthesized; a + supplied value must still match. See the pinned Explainer's type bounds and + resource substitution rules, and Wasmtime `component/matching.rs` for this + host policy. + + Core-module matching also needs typed import/export metadata: the JS module + reflection API exposes names and kinds, not signatures or limits. Do not + claim to check module subtyping from that reflection alone. Named-instance + wiring in the WAST harness must preserve exported resource identities and + module metadata; missing providers must not satisfy a type-mismatch assertion. + Cover matching, mismatching, unused, and omitted imports, and verify rejection + before start-function side effects. This is a follow-up design constraint, + not an optional field or a change to formatVersion 5; the schema and its + version transition must be reviewed together with the implementation. - `values` section (the component-level value-definition feature): out of scope (wasmtime parity, docs/architecture.md ยง7). - Imported-module instantiation (`InstantiateModule::Import`) and re-export diff --git a/harness/README.md b/harness/README.md index 9101f8c..d3c22f8 100644 --- a/harness/README.md +++ b/harness/README.md @@ -135,6 +135,11 @@ That row-specific diagnostic divergence is spec-compatible, but it is not a global message equivalence because the runtime text also covers a distinct successful-prior-write condition. +Variant lifting reports the rejected discriminant and case-count range exactly. +Link-error assertions accept native link failures and host-resource import type +mismatches; native start traps, translation failures, and generic plan errors +remain distinct. + At the current pin, the remaining async subset is 13 classified failures and zero skips: those eleven diagnostic rows, the unavailable native `set-max-table-capacity` provider row, and its one no-current-instance cascade. diff --git a/harness/src/runner.ts b/harness/src/runner.ts index a40f2f9..dcf3d16 100644 --- a/harness/src/runner.ts +++ b/harness/src/runner.ts @@ -246,7 +246,14 @@ class FileRunner { try { await this.executor.instantiate(artifact, "link-error"); } catch (e) { - if (e instanceof LinkError) return undefined; + if (e instanceof LinkError) { + if (!trapMatches(command.text, e.message)) { + throw new Error( + `expected link error "${command.text}", got "${e.message}"`, + ); + } + return undefined; + } throw e; } throw new Error( @@ -331,6 +338,26 @@ const TRAP_MESSAGE_EQUIVALENTS: Array< // Pinned Wasmtime resources.wast:459-481 passes literal slot 2 to the // outer component's empty table; Table.get rejects it as out of range. ["unknown handle index 2", ["table index out of range"]], + // Pinned Wasmtime resources.wast:927 and definitions.py lift_own + // (third_party/component-model/design/mvp/canonical-abi/definitions.py:1482). + ["cannot remove owned resource while borrowed", ["handle still lent out"]], + // Pinned Wasmtime strings.wast:21,23 and definitions.py + // load_string_from_range (definitions.py:1395). + ["string pointer not aligned to 2", ["misaligned string pointer"]], + // Pinned Wasmtime resources.wast:167,174. The executor emits these only + // from its dedicated host-resource import type verdict. + [ + "was not found", + [ + "host import 'host/missing' must be a HostResourceType (the component imports a resource type); got undefined", + ], + ], + [ + "expected resource found func", + [ + "host import 'host/return-three' must be a HostResourceType (the component imports a resource type); got a function", + ], + ], // Official resources/handle-table.wast:322,324 and the corresponding // resource-type checks in runtime/src/cabi/handles.ts:239-263. [ diff --git a/harness/src/runtime-executor.ts b/harness/src/runtime-executor.ts index 968a9b7..414def1 100644 --- a/harness/src/runtime-executor.ts +++ b/harness/src/runtime-executor.ts @@ -13,6 +13,7 @@ import { Translator } from "@polyengine/runtime/shim"; import { type ComponentHandle, type HostImports, + HostResourceImportTypeError, instantiateComponent, } from "../../runtime/src/exec/mod.ts"; import { @@ -63,6 +64,30 @@ function maybeCapability(e: unknown, what: string): void { } } +/** Internal instantiation-verdict mapper, exported only for harness tests. */ +export function rethrowInstantiationError( + e: unknown, + expect: InstantiateExpectation, +): never { + if (e instanceof Trap) { + if (expect === "trap") throw new TrapError(e.message); + asCapabilityOrRethrow(e, "instantiate"); + } + if (e instanceof WebAssembly.RuntimeError && expect === "trap") { + throw new TrapError(e.message); + } + if ( + expect === "link-error" && + (e instanceof HostResourceImportTypeError || + e instanceof WebAssembly.LinkError) + ) { + throw new LinkError(e instanceof Error ? e.message : String(e)); + } + if (e instanceof PlanError) asCapabilityOrRethrow(e, "instantiate"); + maybeCapability(e, "instantiate"); + throw e; +} + interface ComponentInstanceRef extends InstanceRef { readonly kind: "component"; handle: ComponentHandle; @@ -181,16 +206,7 @@ export class RuntimeExecutor implements CommandExecutor { trapOnIdle: true, }); } catch (e) { - if (e instanceof Trap) { - if (expect === "trap") throw new TrapError(e.message); - asCapabilityOrRethrow(e, "instantiate"); - } - if (e instanceof PlanError) asCapabilityOrRethrow(e, "instantiate"); - // Capability-gated trampolines (UnsupportedFeatureError et al.) match - // via CAPABILITY_MARKERS regardless of error class: - maybeCapability(e, "instantiate"); - if (expect === "link-error") throw new LinkError(String(e)); - throw e; + rethrowInstantiationError(e, expect); } if (expect !== "success") { throw new Error( diff --git a/harness/src/wasmtime-expectations.ts b/harness/src/wasmtime-expectations.ts index a2d374e..b877578 100644 --- a/harness/src/wasmtime-expectations.ts +++ b/harness/src/wasmtime-expectations.ts @@ -97,33 +97,6 @@ export const WASMTIME_EXPECTATION_GROUPS: readonly WasmtimeExpectationGroup[] = status: "failed", }], }, - { - file: "resources.json", - rows: [{ - lines: [927], - cause: - 'Error: expected trap "cannot remove owned resource while borrowed", got "handle still lent out"', - status: "failed", - }], - }, - { - file: "strings.json", - rows: [{ - lines: [21, 23], - cause: - 'Error: expected trap "string pointer not aligned to 2", got "misaligned string pointer"', - status: "failed", - }], - }, - { - file: "types.json", - rows: [{ - lines: [378], - cause: - 'Error: expected trap "discriminant 2 out of range [0..2)", got "invalid variant discriminant"', - status: "failed", - }], - }, ], }, { @@ -212,14 +185,6 @@ export const WASMTIME_EXPECTATION_GROUPS: readonly WasmtimeExpectationGroup[] = status: "failed", }], }, - { - file: "instance.json", - rows: [{ - lines: [79], - cause: "RuntimeError: unreachable", - status: "failed", - }], - }, { file: "linking.json", rows: [{ @@ -271,16 +236,6 @@ export const WASMTIME_EXPECTATION_GROUPS: readonly WasmtimeExpectationGroup[] = { file: "resources.json", rows: [{ - lines: [167], - cause: - "PlanError: host import 'host/missing' must be a HostResourceType (the component imports a resource type); got undefined", - status: "failed", - }, { - lines: [174], - cause: - "PlanError: host import 'host/return-three' must be a HostResourceType (the component imports a resource type); got a function", - status: "failed", - }, { lines: [201], cause: "Error: expected instantiation link-error, but component instantiated successfully", diff --git a/harness/tests/runner_unit_test.ts b/harness/tests/runner_unit_test.ts index 1c364b7..6a3658f 100644 --- a/harness/tests/runner_unit_test.ts +++ b/harness/tests/runner_unit_test.ts @@ -8,7 +8,7 @@ import type { WastJson } from "../src/schema.ts"; import { CoreOnlyExecutor } from "../src/executor.ts"; import type { CommandExecutor } from "../src/executor.ts"; -import { TrapError } from "../src/executor.ts"; +import { LinkError, TrapError } from "../src/executor.ts"; import { runWastJson, trapMatches } from "../src/runner.ts"; // (module) - the empty core module, hand-encoded. @@ -204,6 +204,34 @@ Deno.test("assert_uninstantiable rejects an unrelated trap cause", async () => { assertEq(result.results[0].status, "failed", "status"); }); +Deno.test("assert_unlinkable requires a matching link diagnostic", async () => { + for ( + const [message, status] of [ + ["expected resource found func", "passed"], + ["different cause", "failed"], + [ + "prefix host import 'host/return-three' must be a HostResourceType (the component imports a resource type); got a function", + "failed", + ], + ] as const + ) { + const executor = new CoreOnlyExecutor() as CommandExecutor; + executor.instantiate = () => Promise.reject(new LinkError(message)); + const result = await runWastJson( + doc([{ + type: "assert_unlinkable", + line: 1, + filename: "comp.0.wasm", + module_type: "binary", + text: "expected resource found func", + }]), + load, + executor, + ); + assertEq(result.results[0].status, status, message); + } +}); + // Exact diagnostic equivalents: the core `unreachable` trap row. The runtime // (runtime/src/exec/boundary.ts mapCoreException) passes each JS engine's raw // trap text through untouched; this table is where the suite's @@ -373,12 +401,44 @@ Deno.test("trapMatches: verified diagnostic equivalents match only their named o "wasm `unreachable` instruction executed", "guest trapped: unreachable", ], + [ + "cannot remove owned resource while borrowed", + "handle still lent out", + ], + ["string pointer not aligned to 2", "misaligned string pointer"], + [ + "was not found", + "host import 'host/missing' must be a HostResourceType (the component imports a resource type); got undefined", + ], + [ + "expected resource found func", + "host import 'host/return-three' must be a HostResourceType (the component imports a resource type); got a function", + ], ]; for (const [expected, actual] of equivalents) { assertEq(trapMatches(expected, actual), true, `${expected} / ${actual}`); } }); +Deno.test("generic variant diagnostic is not globally equivalent", () => { + assertEq( + trapMatches( + "discriminant 2 out of range [0..2)", + "invalid variant discriminant", + ), + false, + "global matcher", + ); + assertEq( + trapMatches( + "discriminant 2 out of range [0..2)", + "discriminant 3 out of range [0..3)", + ), + false, + "different discriminant and case count", + ); +}); + Deno.test("trapMatches: narrow diagnostic rows reject adjacent but different traps", () => { const nonEquivalents: Array<[string, string]> = [ ["integer overflow", "integer underflow"], @@ -407,6 +467,11 @@ Deno.test("trapMatches: narrow diagnostic rows reject adjacent but different tra "uncaught exception propagated out of component", "guest trapped: unreachable", ], + ["string pointer not aligned to 2", "misaligned list pointer"], + [ + "was not found", + "prefix host import 'host/missing' must be a HostResourceType (the component imports a resource type); got undefined", + ], ]; for (const [expected, actual] of nonEquivalents) { assertEq(trapMatches(expected, actual), false, `${expected} / ${actual}`); @@ -440,6 +505,11 @@ Deno.test("trapMatches: exact equivalents reject expected and actual affixes", ( "invalid `task.return` signature and/or options for current task", "task.return with canonical options differing from the task's", ], + ["string pointer not aligned to 2", "misaligned string pointer"], + [ + "expected resource found func", + "host import 'host/return-three' must be a HostResourceType (the component imports a resource type); got a function", + ], ] ) { for ( diff --git a/harness/tests/runtime_validation_test.ts b/harness/tests/runtime_validation_test.ts index 2cf5e05..8d8ccb2 100644 --- a/harness/tests/runtime_validation_test.ts +++ b/harness/tests/runtime_validation_test.ts @@ -1,8 +1,13 @@ import { PlanError, TranslateError } from "@polyengine/runtime/plan"; +import { HostResourceImportTypeError } from "../../runtime/src/exec/mod.ts"; import { Translator } from "@polyengine/runtime/shim"; +import { LinkError, TrapError } from "../src/executor.ts"; import type { Artifact } from "../src/executor.ts"; import { runWastJson } from "../src/runner.ts"; -import { RuntimeExecutor } from "../src/runtime-executor.ts"; +import { + rethrowInstantiationError, + RuntimeExecutor, +} from "../src/runtime-executor.ts"; const shim = await Deno.readFile( new URL( @@ -150,3 +155,46 @@ Deno.test("runtime validation: pipeline failures propagate unchanged and fail ne Translator.prototype.translate = original; } }); + +Deno.test("runtime instantiation verdicts preserve failure phase", async () => { + const nativeTrap = new WebAssembly.RuntimeError("unreachable"); + const mappedTrap = await thrown(() => + rethrowInstantiationError(nativeTrap, "trap") + ); + if ( + !(mappedTrap instanceof TrapError) || mappedTrap.message !== "unreachable" + ) { + throw new Error(`native trap was not preserved: ${mappedTrap}`); + } + if ( + await thrown(() => rethrowInstantiationError(nativeTrap, "link-error")) !== + nativeTrap + ) { + throw new Error("native trap satisfied a link-error expectation"); + } + + const resourceMismatch = new HostResourceImportTypeError("wrong resource"); + const mappedLink = await thrown(() => + rethrowInstantiationError(resourceMismatch, "link-error") + ); + if ( + !(mappedLink instanceof LinkError) || + mappedLink.message !== "wrong resource" + ) { + throw new Error(`resource mismatch was not a link error: ${mappedLink}`); + } + + for ( + const error of [ + new PlanError("generic plan failure"), + new Error("generic executor failure"), + ] + ) { + if ( + await thrown(() => rethrowInstantiationError(error, "link-error")) !== + error + ) { + throw new Error(`${error} satisfied a link-error expectation`); + } + } +}); diff --git a/harness/tests/wasmtime_provider_integration.ts b/harness/tests/wasmtime_provider_integration.ts index f25c70c..3889d97 100644 --- a/harness/tests/wasmtime_provider_integration.ts +++ b/harness/tests/wasmtime_provider_integration.ts @@ -2,13 +2,26 @@ import type { WastJson } from "../src/schema.ts"; import { runWastJson } from "../src/runner.ts"; import { RuntimeExecutor } from "../src/runtime-executor.ts"; +const root = new URL("../../", import.meta.url); +const generated = new URL("harness/generated-wasmtime/", root); + +async function runtimeExecutor(): Promise { + return await RuntimeExecutor.create( + await Deno.readFile( + new URL( + "target/wasm32-unknown-unknown/release/translator_shim.wasm", + root, + ), + ), + ); +} + function assert(condition: boolean, message: string): void { if (!condition) throw new Error(message); } Deno.test("context-in-resource-drop crosses the scoped gc host boundary", async () => { const { wasmtimeSpectest } = await import("../src/wasmtime-spectest.ts"); - const root = new URL("../../", import.meta.url); const generated = new URL( "harness/generated-wasmtime/async/", root, @@ -43,3 +56,76 @@ Deno.test("context-in-resource-drop crosses the scoped gc host boundary", async `expected four destructor host-boundary calls, got ${probe.counters.forcedHostBoundaries}`, ); }); + +Deno.test("types fixture receives the precise variant diagnostic", async () => { + const fixture = new URL("types.json", generated); + const original = JSON.parse(await Deno.readTextFile(fixture)) as WastJson; + const doc: WastJson = { + ...original, + commands: original.commands.filter((command) => command.line >= 357), + }; + const result = await runWastJson( + doc, + (name) => Deno.readFile(new URL(name, generated)), + await runtimeExecutor(), + ); + assert( + result.results.length === 5 && + result.results.every((row) => row.status === "passed"), + `types fixture slice did not pass: ${JSON.stringify(result.results)}`, + ); +}); + +Deno.test("native start trap does not satisfy assert_unlinkable", async () => { + const fixture = new URL("instance.json", generated); + const original = JSON.parse(await Deno.readTextFile(fixture)) as WastJson; + const source = original.commands.find((command) => command.line === 79); + if (source === undefined || source.type !== "assert_uninstantiable") { + throw new Error("generated native start-trap fixture is absent"); + } + const doc: WastJson = { + ...original, + commands: [{ ...source, type: "assert_unlinkable" }], + }; + const result = await runWastJson( + doc, + (name) => Deno.readFile(new URL(name, generated)), + await runtimeExecutor(), + ); + assert( + result.results[0]?.status === "failed" && + result.results[0].detail === "RuntimeError: unreachable", + `native start trap satisfied unlinkable: ${JSON.stringify(result.results)}`, + ); +}); + +Deno.test("unsupported translation does not satisfy assert_unlinkable", async () => { + const fixture = new URL( + "tests/fixtures/validation-imported-module.wasm", + new URL("harness/", root), + ); + const doc: WastJson = { + source_filename: "unsupported.wast", + commands: [{ + type: "assert_unlinkable", + line: 1, + filename: "validation-imported-module.wasm", + module_type: "binary", + text: "anything", + }], + }; + const result = await runWastJson( + doc, + () => Deno.readFile(fixture), + await runtimeExecutor(), + ); + assert( + result.results[0]?.status === "failed" && + result.results[0].detail?.startsWith( + "TranslateError: translator error [unsupported]", + ) === true, + `unsupported translation satisfied unlinkable: ${ + JSON.stringify(result.results) + }`, + ); +}); diff --git a/runtime/src/cabi/lift.ts b/runtime/src/cabi/lift.ts index 88c49a1..86946e3 100644 --- a/runtime/src/cabi/lift.ts +++ b/runtime/src/cabi/lift.ts @@ -210,7 +210,10 @@ export function liftFlatVariant( const flatTypes = flattenVariant(cases, cx.opts); assert_(flatTypes.shift() === "i32"); const caseIndex = vi.next("i32") as number; - trapIf(caseIndex >= cases.length, "invalid variant discriminant"); + trapIf( + caseIndex >= cases.length, + `discriminant ${caseIndex} out of range [0..${cases.length})`, + ); const coerceIter: ValueIter = { next(want: CoreType): CoreValue { const have = flatTypes.shift()!; diff --git a/runtime/src/cabi/load.ts b/runtime/src/cabi/load.ts index 740ef06..a9b39f5 100644 --- a/runtime/src/cabi/load.ts +++ b/runtime/src/cabi/load.ts @@ -171,7 +171,10 @@ export function loadVariant( ): ComponentValue { const mem = requireMemory(cx.opts); const caseIndex = loadIntU(mem, ptr, discSize); - trapIf(caseIndex >= cases.length, "invalid variant discriminant"); + trapIf( + caseIndex >= cases.length, + `discriminant ${caseIndex} out of range [0..${cases.length})`, + ); const c = cases[caseIndex]; if (c.type === null) return { kind: c.label, value: null }; return { kind: c.label, value: load(cx, ptr + payloadOffset, c.type) }; diff --git a/runtime/src/exec/executor.ts b/runtime/src/exec/executor.ts index 03f6b0c..59ee437 100644 --- a/runtime/src/exec/executor.ts +++ b/runtime/src/exec/executor.ts @@ -121,6 +121,10 @@ export function hostResourceType( return new HostResourceType(options ?? {}); } +/** Internal executor verdict for a host resource import of the wrong kind. */ +export class HostResourceImportTypeError extends PlanError { +} + export interface InstantiateInput { plan: WirePlan; /** The original component binary (embedded modules are sliced from it). */ @@ -500,7 +504,7 @@ class Executor { const label = importLabel(imp.name, imp.path); const value = this.lookupHostImport(imp.name, imp.path, label); if (!(value instanceof HostResourceType)) { - throw new PlanError( + throw new HostResourceImportTypeError( `host import '${label}' must be a HostResourceType (the component ` + `imports a resource type); got ${describe(value)}`, ); diff --git a/runtime/tests/variant_diagnostic_test.ts b/runtime/tests/variant_diagnostic_test.ts new file mode 100644 index 0000000..cd0b1ea --- /dev/null +++ b/runtime/tests/variant_diagnostic_test.ts @@ -0,0 +1,45 @@ +import { + type CaseType, + CoreValueIter, + liftFlatVariant, + loadVariant, + MemInst, + Trap, +} from "../src/cabi/mod.ts"; +import { mkCx } from "./support/driver.ts"; + +function cases(length: number): CaseType[] { + return Array.from({ length }, (_, i) => ({ + label: `case-${i}`, + type: null, + })); +} + +function assertExactTrap(fn: () => unknown, expected: string): void { + try { + fn(); + } catch (error) { + if (error instanceof Trap && error.message === expected) return; + throw new Error(`expected Trap(${expected}), got ${String(error)}`); + } + throw new Error(`expected Trap(${expected}), but returned`); +} + +Deno.test("memory variant traps include the discriminant and case count", () => { + for (const [tag, count] of [[2, 2], [3, 3]] as const) { + const memory = new MemInst(new Uint8Array([tag]), "i32"); + assertExactTrap( + () => loadVariant(mkCx(memory), 0, cases(count), 1, 1), + `discriminant ${tag} out of range [0..${count})`, + ); + } +}); + +Deno.test("flat variant traps include the discriminant and case count", () => { + for (const [tag, count] of [[2, 2], [3, 3]] as const) { + assertExactTrap( + () => liftFlatVariant(mkCx(), new CoreValueIter([tag]), cases(count)), + `discriminant ${tag} out of range [0..${count})`, + ); + } +});