diff --git a/AGENTS.md b/AGENTS.md index 8cbb9e6..f473bc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ cargo test -p translator-shim -p bindgen -p testgen (cd wasi-shims && deno task test) (cd ct-runner && deno task test) (cd ports/websocket && deno task test) # + deno task conformance (spawns their echod) +deno run --allow-read tools/smoke-tls/run.ts --exec # polymorph-tls suite (issue #18) (cd ports/webcrypto && deno test --allow-read tests/) (cd ports/webrtc && deno test -A webrtc.test.ts) deno run -A tools/browser/run-lane.ts chromium # firefox / webkit likewise diff --git a/docs/consumers.md b/docs/consumers.md index 01776f4..8220b29 100644 --- a/docs/consumers.md +++ b/docs/consumers.md @@ -39,13 +39,18 @@ Their jco blockers map one-for-one onto this project's proven strengths most important interfaces are exactly these. The embedder-api contract carries paper signatures for a representative WASI slice; the `wasi-shims/` package is the executable check. -- **Their suites become our gates.** This family surfaced at least five - distinct jco defect classes that no WAST corpus expresses (long-lived - composed workloads, background pumps, cross-task wakeups, codegen-shape - triggers) — and two deltic runtime defects (smoke-c0's R-1/R-2) the same - way. The polymorph matrices and experiment-mosh gates run as release - gates ([#6](https://github.com/lann/deltic/issues/6)) — - necessary-not-sufficient discipline applied to ourselves. +- **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 + [#6](https://github.com/lann/deltic/issues/6)). This family surfaced at + least five distinct jco defect classes that no WAST corpus expresses + (long-lived composed workloads, background pumps, cross-task wakeups, + codegen-shape triggers) — and five deltic runtime defects the same way + (smoke-c0's R-1/R-2; the tls smoke's three, + [#18](https://github.com/lann/deltic/issues/18)) — so running them is + high-yield. But everything on both sides is unstable and co-evolves in + tandem: a consumer-suite delta is a finding to triage, never a blocker + for upstreaming or release. - **What replacing jco does not replace**: componentize-js/-go (guest production — out of scope per [architecture.md §2](architecture.md); their output components are ordinary inputs to us) and the wasmtime host legs @@ -89,6 +94,7 @@ Reference implementations developed here, pending upstreaming | `exams/iroh-endpoint` | the endpoint exit exam | 5/5: bind+identity, relay echo, WebRTC upgrade, jco#11/#13 assertions, teardown | | `ct-runner` | L3 runner for the polymorph-test L1 contract | golden-tested L4 JSONL; drives the websocket suite | | `tools/smoke-c0` | C0 smoke legs + report | legs 1–4 (`REPORT.md`) | +| `tools/smoke-tls` | polymorph-tls conformance under deltic ([#18](https://github.com/lann/deltic/issues/18)) | translate 8/8; suites 6/6 applicable green per target (named xfails: tag-gating [#25](https://github.com/lann/deltic/issues/25), callback-null-context [#24](https://github.com/lann/deltic/issues/24)) | Deferred consumer surfaces: experiment-mosh deep E2E ([#2](https://github.com/lann/deltic/issues/2)), webcrypto family completion diff --git a/harness/browser/expectations/chromium.ts b/harness/browser/expectations/chromium.ts index 7e33629..aea534b 100644 --- a/harness/browser/expectations/chromium.ts +++ b/harness/browser/expectations/chromium.ts @@ -44,9 +44,9 @@ export const chromium: LaneExpectation = { totals: { commands: 1395, executed: 1349, - passed: 1250, + passed: 1254, failed: 0, - xfail: 99, + xfail: 95, pendingRuntime: 41, pendingCapability: 0, unsupportedDirective: 5, diff --git a/harness/browser/expectations/firefox.ts b/harness/browser/expectations/firefox.ts index 013b715..e6c35b0 100644 --- a/harness/browser/expectations/firefox.ts +++ b/harness/browser/expectations/firefox.ts @@ -48,9 +48,9 @@ export const firefox: LaneExpectation = { totals: { commands: 1395, executed: 1349, - passed: 1250, + passed: 1254, failed: 0, - xfail: 99, + xfail: 95, pendingRuntime: 41, pendingCapability: 0, unsupportedDirective: 5, diff --git a/harness/browser/expectations/webkit.ts b/harness/browser/expectations/webkit.ts index 278a028..05bb9ae 100644 --- a/harness/browser/expectations/webkit.ts +++ b/harness/browser/expectations/webkit.ts @@ -1297,9 +1297,13 @@ export const webkit: LaneExpectation = { executed: 1349, // 18 commands moved from xfail to passed when FINDING M3A-1 was fixed in // the runtime and its entries left this file. - passed: 1076, + // +4 more (async/drop-cross-task-borrow:305,307, async/passing-resources + // :175,176) when the #18 tls-smoke fixes pruned their shared xfail + // entries — arithmetic update, NOT re-measured (webkit is not runnable + // on this dev host); the post-merge webkit lane is the check. + passed: 1080, failed: 0, - xfail: 273, + xfail: 269, pendingRuntime: 41, pendingCapability: 0, unsupportedDirective: 5, diff --git a/harness/shell/expectations/jsc-pinned.ts b/harness/shell/expectations/jsc-pinned.ts index 6d603c0..b6857bf 100644 --- a/harness/shell/expectations/jsc-pinned.ts +++ b/harness/shell/expectations/jsc-pinned.ts @@ -46,9 +46,9 @@ export const jscPinned: ShellLaneExpectation = { totals: { commands: 1395, executed: 1349, - passed: 1250, + passed: 1254, failed: 0, - xfail: 99, + xfail: 95, pendingRuntime: 41, pendingCapability: 0, unsupportedDirective: 5, diff --git a/harness/shell/expectations/jsc-trunk.ts b/harness/shell/expectations/jsc-trunk.ts index 7aa64b7..7c91937 100644 --- a/harness/shell/expectations/jsc-trunk.ts +++ b/harness/shell/expectations/jsc-trunk.ts @@ -1,7 +1,7 @@ // JSC trunk lane expectation — a findings lane (best-effort, non-gating; // issue #22). Seeded from the first successful CI run (2026-08-09, GH // Actions ubuntu-24.04 x64, bundle rev `318852@main` built 2026-08-08): -// **EXACT Deno-lane parity** — 1250 passed / 0 failed / 99 xfail (the Deno +// **EXACT Deno-lane parity** — 1254 passed / 0 failed / 95 xfail (the Deno // lane's own classes, identical classification), zero jsc-specific deltas, // and the full capability matrix true: JSPI (round trip verified), // multi-memory, wasm-GC, exception-handling, memory64, tail-calls, @@ -63,9 +63,9 @@ export const jscTrunk: ShellLaneExpectation = { totals: { commands: 1395, executed: 1349, - passed: 1250, + passed: 1254, failed: 0, - xfail: 99, + xfail: 95, pendingRuntime: 41, pendingCapability: 0, unsupportedDirective: 5, diff --git a/harness/shell/expectations/sm-nightly.ts b/harness/shell/expectations/sm-nightly.ts index a50a4fd..6fdf1b4 100644 --- a/harness/shell/expectations/sm-nightly.ts +++ b/harness/shell/expectations/sm-nightly.ts @@ -56,9 +56,9 @@ export const smNightly: ShellLaneExpectation = { totals: { commands: 1395, executed: 1349, - passed: 1250, + passed: 1254, failed: 0, - xfail: 99, + xfail: 95, pendingRuntime: 41, pendingCapability: 0, unsupportedDirective: 5, diff --git a/harness/shell/expectations/sm-pinned.ts b/harness/shell/expectations/sm-pinned.ts index 7341484..41eba43 100644 --- a/harness/shell/expectations/sm-pinned.ts +++ b/harness/shell/expectations/sm-pinned.ts @@ -23,7 +23,7 @@ // RESULT (2026-08-09, seed run — this host is aarch64; the release-153 // jsshell ships an aarch64 build, sha256-verified via pins.json): // **the lane runs the full corpus, EXACT Deno-lane parity.** All 59 files, -// 1395 commands, 1349 executed, 1250 passed, 0 failed, 99 xfail, 41 +// 1395 commands, 1349 executed, 1254 passed, 0 failed, 95 xfail, 41 // pending-runtime, 0 pending-capability, 5 unsupported-directive. Zero // deltas, zero stale xfails, zero unexpected failures. Capability matrix: // jspi = {suspending: true, promising: true, roundTrip: true}, multiMemory @@ -51,9 +51,9 @@ export const smPinned: ShellLaneExpectation = { totals: { commands: 1395, executed: 1349, - passed: 1250, + passed: 1254, failed: 0, - xfail: 99, + xfail: 95, pendingRuntime: 41, pendingCapability: 0, unsupportedDirective: 5, diff --git a/harness/src/xfail.ts b/harness/src/xfail.ts index d986256..e5e5699 100644 --- a/harness/src/xfail.ts +++ b/harness/src/xfail.ts @@ -284,20 +284,8 @@ export const XFAIL: XfailEntry[] = [ // flip: a start-function SuspendError maps to "cannot block a synchronous // task before returning"); entry pruned. --- // --- async/drop-cross-task-borrow.json: root cause: FACT-ASYNC --- - { - file: "async/drop-cross-task-borrow.json", - line: 305, - reason: - "observed: AssertionError: transfer-borrow outside an " + - "enter-sync-call/exit-sync-call bracket", - }, - { - file: "async/drop-cross-task-borrow.json", - line: 307, - reason: - "observed: AssertionError: transfer-borrow outside an " + - "enter-sync-call/exit-sync-call bracket", - }, + // lines 305/307 GREEN after the #18 tls-smoke fixes (FACT [async-start] + // borrow window + ResourceTypeInfo unification); entries pruned. { file: "async/drop-cross-task-borrow.json", line: 309, @@ -321,23 +309,9 @@ export const XFAIL: XfailEntry[] = [ // --- async/futures-must-write.json: root cause: STREAMS --- // --- async/partial-stream-copies.json: GREEN under jspi auto-detection // (M2 flip); entry pruned. --- - // --- async/passing-resources.json: root cause: STREAMS --- - { - file: "async/passing-resources.json", - line: 175, - reason: - "observed: TypeError: Converting circular structure to JSON " + - "--> starting at object with constructor 'Object' | " + - "property 'rt' -> object with constructor " + - "'ResourceTypeInfo' | property", - }, - { - file: "async/passing-resources.json", - line: 176, - reason: - "observed: Error: expected trap \"unknown handle index 3\", " + - "got \"wasm trap: wasm `unreachable` instruction executed\"", - }, + // --- async/passing-resources.json: lines 175/176 GREEN after the #18 + // tls-smoke fixes (cycle-safe structural ValType equality + token + // unification); entries pruned. --- // --- async/same-component-stream-future.json: root cause: STREAMS --- // --- async/sync-barges-in.json: GREEN under jspi auto-detection (M2 // flip); entry pruned. --- diff --git a/runtime/src/cabi/async_values.ts b/runtime/src/cabi/async_values.ts index 50a5dc6..aed671f 100644 --- a/runtime/src/cabi/async_values.ts +++ b/runtime/src/cabi/async_values.ts @@ -25,7 +25,7 @@ import { assert_, trapIf } from "./trap.ts"; import type { LiftLowerContext } from "./context.ts"; import type { BorrowType, OwnType, ValType } from "./types.ts"; -import { contains } from "./types.ts"; +import { contains, fmtValType } from "./types.ts"; import { CopyState, ErrorContext, @@ -123,13 +123,18 @@ export function lowerStream( // `ValType` (typed derivation is bindgen's job), so this is the first point // at which a mismatch against the guest's declared `stream` can be // caught — and a silent mismatch would corrupt every copy, since the - // element type is what sizes and lifts the buffer. + // element type is what sizes and lifts the buffer. `fmtValType`, not + // `JSON.stringify`: the latter throws on resource-bearing element types + // (cabi/types.ts `valTypeEqual` contract note) — and as a template-literal + // argument it was evaluated even when the assertion PASSED. const declared = (t as { element?: ValType | null }).element ?? null; - assert_( - sameElemType(v.t, declared), - `stream element type mismatch: host end carries ` + - `${JSON.stringify(v.t)}, callee expects ${JSON.stringify(declared)}`, - ); + if (!sameElemType(v.t, declared)) { + assert_( + false, + `stream element type mismatch: host end carries ` + + `${fmtValType(v.t)}, callee expects ${fmtValType(declared)}`, + ); + } const inst = cx.inst; assert_(inst !== null, "stream lower requires a component instance"); (v as { boundStore?: unknown }).boundStore ??= @@ -150,11 +155,13 @@ export function lowerFuture( ); assert_(!containsBorrow(t), "future may not contain a borrow"); const declared = (t as { element?: ValType | null }).element ?? null; - assert_( - sameElemType(v.t, declared), - `future element type mismatch: host end carries ` + - `${JSON.stringify(v.t)}, callee expects ${JSON.stringify(declared)}`, - ); + if (!sameElemType(v.t, declared)) { + assert_( + false, + `future element type mismatch: host end carries ` + + `${fmtValType(v.t)}, callee expects ${fmtValType(declared)}`, + ); + } const inst = cx.inst; assert_(inst !== null, "future lower requires a component instance"); (v as { boundStore?: unknown }).boundStore ??= diff --git a/runtime/src/cabi/types.ts b/runtime/src/cabi/types.ts index de63aa4..c4a2707 100644 --- a/runtime/src/cabi/types.ts +++ b/runtime/src/cabi/types.ts @@ -328,3 +328,140 @@ export function contains( return p(d); } } + +// --------------------------------------------------------------------------- +// Structural ValType equality and display +// --------------------------------------------------------------------------- + +/** + * Structural `ValType` equality. + * + * CONTRACT (bugfix, TRACK C2-D; generalized during the #18 tls smoke): naive + * `JSON.stringify(a) === JSON.stringify(b)` recurses into `own`/`borrow`'s + * `ResourceTypeInfo` — a class whose `impl` field is documented "Compared by + * object identity everywhere" (see `ResourceTypeInfo` above) and which cycles + * back to the owning instance state (`impl.handles` holds live resource + * tables that reference their types), so `JSON.stringify` throws + * `TypeError: Converting circular structure to JSON` on ANY type containing + * `own`/`borrow` at any depth. First hit by `task.return` result types + * (C2-D, polymorph-test's `list>`), then by stream/future + * element types (polymorph-tls streams carrying resource-bearing payloads). + * Object-identity types (`ResourceTypeInfo`) are compared by reference, per + * the documented invariant. + */ +export function valTypesEqual(a: ValType[], b: ValType[]): boolean { + return a.length === b.length && a.every((t, i) => valTypeEqual(t, b[i])); +} + +export function valTypeEqual(a: ValType, b: ValType): boolean { + if (a === b) return true; + if (a.kind !== b.kind) return false; + switch (a.kind) { + case "list": { + const bb = b as typeof a; + return a.length === bb.length && valTypeEqual(a.element, bb.element); + } + case "record": { + const bb = b as typeof a; + return a.fields.length === bb.fields.length && + a.fields.every((f, i) => + f.label === bb.fields[i].label && valTypeEqual(f.type, bb.fields[i].type) + ); + } + case "tuple": { + const bb = b as typeof a; + return a.elements.length === bb.elements.length && + a.elements.every((e, i) => valTypeEqual(e, bb.elements[i])); + } + case "variant": { + const bb = b as typeof a; + return a.cases.length === bb.cases.length && + a.cases.every((c, i) => { + const other = bb.cases[i]; + if (c.label !== other.label) return false; + if (c.type === null || other.type === null) return c.type === other.type; + return valTypeEqual(c.type, other.type); + }); + } + case "enum": + case "flags": { + const bb = b as typeof a; + return a.labels.length === bb.labels.length && + a.labels.every((l, i) => l === bb.labels[i]); + } + case "option": { + const bb = b as typeof a; + return valTypeEqual(a.type, bb.type); + } + case "result": { + const bb = b as typeof a; + if ((a.ok === null) !== (bb.ok === null)) return false; + if ((a.error === null) !== (bb.error === null)) return false; + return (a.ok === null || valTypeEqual(a.ok, bb.ok!)) && + (a.error === null || valTypeEqual(a.error, bb.error!)); + } + case "map": { + const bb = b as typeof a; + return valTypeEqual(a.key, bb.key) && valTypeEqual(a.value, bb.value); + } + case "own": + case "borrow": { + const bb = b as typeof a; + // Object-identity type (documented invariant): reference equality only. + return a.rt === bb.rt; + } + case "stream": + case "future": { + const bb = b as typeof a; + if ((a.element === null) !== (bb.element === null)) return false; + return a.element === null || valTypeEqual(a.element, bb.element!); + } + case "error-context": + return true; + default: + // Remaining kinds (primitives) carry no extra fields beyond `kind`. + return true; + } +} + +/** + * Cycle-safe display form for diagnostics. `JSON.stringify(t)` is UNSAFE on + * any resource-bearing type (see `valTypeEqual`'s contract note); this prints + * the structural shape and elides `ResourceTypeInfo` identities. + */ +export function fmtValType(t: ValType | null): string { + if (t === null) return "_"; + switch (t.kind) { + case "list": + return t.length === undefined + ? `list<${fmtValType(t.element)}>` + : `list<${fmtValType(t.element)}, ${t.length}>`; + case "record": + return `record{${t.fields.map((f) => `${f.label}: ${fmtValType(f.type)}`).join(", ")}}`; + case "tuple": + return `tuple<${t.elements.map(fmtValType).join(", ")}>`; + case "variant": + return `variant{${ + t.cases.map((c) => c.type === null ? c.label : `${c.label}(${fmtValType(c.type)})`) + .join(", ") + }}`; + case "enum": + return `enum{${t.labels.join(", ")}}`; + case "flags": + return `flags{${t.labels.join(", ")}}`; + case "option": + return `option<${fmtValType(t.type)}>`; + case "result": + return `result<${fmtValType(t.ok)}, ${fmtValType(t.error)}>`; + case "map": + return `map<${fmtValType(t.key)}, ${fmtValType(t.value)}>`; + case "own": + case "borrow": + return `${t.kind}`; + case "stream": + case "future": + return `${t.kind}<${fmtValType(t.element)}>`; + default: + return t.kind; + } +} diff --git a/runtime/src/exec/executor.ts b/runtime/src/exec/executor.ts index 3b50d5a..912da97 100644 --- a/runtime/src/exec/executor.ts +++ b/runtime/src/exec/executor.ts @@ -50,6 +50,7 @@ import { createUnsafeIntrinsic, type PreparedCall, type HostTrapState, + type FactStartScope, type SyncCallScope, TranscodeMemory, } from "../intrinsics/mod.ts"; @@ -289,6 +290,8 @@ class Executor { readonly hostResourceTypes = new Map(); /** In-flight sync cross-component calls (see intrinsics `SyncCallScope`). */ readonly syncCallStack: SyncCallScope[] = []; + /** In-flight FACT `[async-start]` borrow windows (intrinsics `FactStartScope`). */ + readonly factStartScopes: FactStartScope[] = []; /** * Core functions exported by a core instance that imports at least one @@ -921,6 +924,7 @@ class Executor { suspensionMode: this.suspensionMode, calleeCanBlock: (fn: unknown) => this.suspendableFuncs.has(fn as object), syncCallStack: this.syncCallStack, + factStartScopes: this.factStartScopes, trapState: this.trapState, loweredImport: (d) => this.buildLoweredImport(d), stats: this.stats, diff --git a/runtime/src/intrinsics/async_builtins.ts b/runtime/src/intrinsics/async_builtins.ts index 8159347..9b87d1d 100644 --- a/runtime/src/intrinsics/async_builtins.ts +++ b/runtime/src/intrinsics/async_builtins.ts @@ -54,6 +54,7 @@ import { store as storeValue, } from "../cabi/mod.ts"; import type { CoreValue, ValType } from "../cabi/types.ts"; +import { valTypesEqual } from "../cabi/types.ts"; import { currentTask, currentThread, @@ -615,98 +616,9 @@ function unpackEvent( return event; } -/** - * Structural `ValType` equality, for the `task.return` result-type check. - * - * CONTRACT (bugfix, TRACK C2-D): this used to be - * `JSON.stringify(a) === JSON.stringify(b)`, which recurses into `own`/ - * `borrow`'s `ResourceTypeInfo` — a class whose `impl` field is documented - * "Compared by object identity everywhere" (cabi/types.ts `ResourceTypeInfo` - * doc comment) and which cycles back to the owning `ComponentInstanceState` - * (`impl.handles` holds live resource tables that reference their types), - * so `JSON.stringify` throws `TypeError: Converting circular structure to - * JSON` on ANY function whose async result type contains `own`/ - * `borrow` at any depth (e.g. `list>` — polymorph-test's - * `tests.all() -> list`, ct-runner's fixture). Never previously - * exercised: no prior async-exported function in this repo's guest corpus - * returned a resource-bearing type. Fixed by a real recursive structural - * walk that compares `ResourceTypeInfo` (and any other object-identity - * type, `InstanceLike`) by reference, per the documented invariant. - */ -function valTypesEqual(a: ValType[], b: ValType[]): boolean { - return a.length === b.length && a.every((t, i) => valTypeEqual(t, b[i])); -} - -function valTypeEqual(a: ValType, b: ValType): boolean { - if (a === b) return true; - if (a.kind !== b.kind) return false; - switch (a.kind) { - case "list": { - const bb = b as typeof a; - return a.length === bb.length && valTypeEqual(a.element, bb.element); - } - case "record": { - const bb = b as typeof a; - return a.fields.length === bb.fields.length && - a.fields.every((f, i) => - f.label === bb.fields[i].label && valTypeEqual(f.type, bb.fields[i].type) - ); - } - case "tuple": { - const bb = b as typeof a; - return a.elements.length === bb.elements.length && - a.elements.every((e, i) => valTypeEqual(e, bb.elements[i])); - } - case "variant": { - const bb = b as typeof a; - return a.cases.length === bb.cases.length && - a.cases.every((c, i) => { - const other = bb.cases[i]; - if (c.label !== other.label) return false; - if (c.type === null || other.type === null) return c.type === other.type; - return valTypeEqual(c.type, other.type); - }); - } - case "enum": - case "flags": { - const bb = b as typeof a; - return a.labels.length === bb.labels.length && - a.labels.every((l, i) => l === bb.labels[i]); - } - case "option": { - const bb = b as typeof a; - return valTypeEqual(a.type, bb.type); - } - case "result": { - const bb = b as typeof a; - if ((a.ok === null) !== (bb.ok === null)) return false; - if ((a.error === null) !== (bb.error === null)) return false; - return (a.ok === null || valTypeEqual(a.ok, bb.ok!)) && - (a.error === null || valTypeEqual(a.error, bb.error!)); - } - case "map": { - const bb = b as typeof a; - return valTypeEqual(a.key, bb.key) && valTypeEqual(a.value, bb.value); - } - case "own": - case "borrow": { - const bb = b as typeof a; - // Object-identity type (documented invariant): reference equality only. - return a.rt === bb.rt; - } - case "stream": - case "future": { - const bb = b as typeof a; - if ((a.element === null) !== (bb.element === null)) return false; - return a.element === null || valTypeEqual(a.element, bb.element!); - } - case "error-context": - return true; - default: - // Remaining kinds (primitives) carry no extra fields beyond `kind`. - return true; - } -} +// Structural `ValType` equality (the C2-D bugfix) moved to cabi/types.ts +// (`valTypesEqual`) when the #18 tls smoke found its stream-element sibling; +// the contract note lives there now. /** Unused-import guard: `trap` is re-exported for symmetry with cabi. */ void trap; diff --git a/runtime/src/intrinsics/fact_calls.ts b/runtime/src/intrinsics/fact_calls.ts index 557d687..5a5e6fb 100644 --- a/runtime/src/intrinsics/fact_calls.ts +++ b/runtime/src/intrinsics/fact_calls.ts @@ -182,6 +182,8 @@ export interface FactCallContext { * preparations can never be outstanding at once. Asserted, not assumed. */ prepared: { current: PreparedCall | null }; + /** See `TrampolineContext.factStartScopes` (intrinsics/mod.ts). */ + factStartScopes: import("./mod.ts").FactStartScope[]; } /** definitions.py-shaped canonical options a FACT task must remember. */ @@ -340,6 +342,14 @@ function mkCalleeTask(input: { * `Task.cancel` -> `on_resolve(None)`). */ onCallerResults: (r: CoreValue[] | null) => void; + /** + * Caller-side lender registrar for borrows transferred during + * `[async-start]` (definitions.py `lift_borrow` line 1517 adds lenders to + * the caller's Subtask). async-start-call passes its `Subtask` (whose + * `deliverResolve` releases them); sync-start-call passes a scope it + * releases when the blocked caller frame gets its results. + */ + lenderScope: { addLender(h: import("../cabi/handles.ts").ResourceHandle): void }; }): { task: Task; body: (t: Thread) => Generator } { const { prepared, callee, callback, postReturn, ctx, calleeUsesAsyncAbi } = input; @@ -387,12 +397,24 @@ function mkCalleeTask(input: { // if they have a result, use the last parameter as a return pointer so // chop that off"). Sync callers forward everything directly. () => { - const calleeArgs = callCore( - prepared.start, - prepared.asyncCallerWithResult - ? prepared.params.slice(0, -1) - : prepared.params, - ) as CoreValue[]; + // Open the FACT borrow window for the duration of the copy adapter: + // `[async-start]` is where argument resource transfers run, and it + // cannot block (see the WASM-ENTRY note below), so push/pop brackets a + // strictly synchronous window. Borrow bookkeeping lands on this + // (callee) task's `numBorrows` and the caller's lender scope — see + // intrinsics/mod.ts `FactStartScope`. + ctx.factStartScopes.push({ taskScope: task, lenders: input.lenderScope }); + let calleeArgs: CoreValue[]; + try { + calleeArgs = callCore( + prepared.start, + prepared.asyncCallerWithResult + ? prepared.params.slice(0, -1) + : prepared.params, + ) as CoreValue[]; + } finally { + ctx.factStartScopes.pop(); + } input.onStarted?.(); return calleeArgs; }, @@ -557,6 +579,22 @@ export function createSyncStartCall( : ctx.callback(decl.callback); let callerResults: CoreValue[] | null = null; + // The caller's frame is blocked for the whole call, so resolution + // delivery = this intrinsic returning results: release lenders then + // (the sync analogue of `Subtask.deliver_resolve`, definitions.py 904). + // Inlined rather than reusing `SyncCallScope` to keep this module free + // of a value-level import cycle with intrinsics/mod.ts. + const lentHandles: { numLends: number }[] = []; + const lenderScope = { + addLender(h: { numLends: number }): void { + h.numLends += 1; + lentHandles.push(h); + }, + releaseLenders(): void { + for (const h of lentHandles) h.numLends -= 1; + lentHandles.length = 0; + }, + }; const { task, body } = mkCalleeTask({ prepared, callee: callee as CoreFn, @@ -571,6 +609,7 @@ export function createSyncStartCall( onCallerResults: (r) => { callerResults = r ?? []; }, + lenderScope, }); // Reference `Store.lift`: the reentrance gate, with the *caller* as the @@ -631,7 +670,10 @@ export function createSyncStartCall( task: currentTask(), readyFunc: () => callerResults !== null, cancellable: false, - produce: () => shapeResults(callerResults as CoreValue[] | null), + produce: () => { + lenderScope.releaseLenders(); + return shapeResults(callerResults as CoreValue[] | null); + }, }); } needsJspi( @@ -639,6 +681,7 @@ export function createSyncStartCall( "first activation (the caller's wasm frame must block)", ); } + lenderScope.releaseLenders(); return shapeResults(callerResults as CoreValue[] | null); }; } @@ -732,6 +775,9 @@ export function createAsyncStartCall( } onProgress(); }, + // Borrow lenders attach to the caller-side subtask, released by its + // `deliverResolve` (definitions.py `Subtask.deliver_resolve`, line 904). + lenderScope: subtask, }); // Cross-component cancellation: `subtask.cancel` forwards to the callee // task's `request_cancellation` (definitions.py line 519), which delivers diff --git a/runtime/src/intrinsics/mod.ts b/runtime/src/intrinsics/mod.ts index a793980..3e609f1 100644 --- a/runtime/src/intrinsics/mod.ts +++ b/runtime/src/intrinsics/mod.ts @@ -210,6 +210,30 @@ export class SyncCallScope { } } +/** + * The borrow bookkeeping of one FACT `[async-start]` argument-copy window — + * the prepare/start protocol's analogue of `SyncCallScope`. Live only for + * the synchronous `callCore(prepared.start, …)` call inside `mkCalleeTask`'s + * `on_start` (the copy adapters cannot block, so the window never suspends). + * + * Reference mapping (definitions.py): `taskScope` is the callee `Task` — + * `lower_borrow` (line 1821) counts `num_borrows` there, and + * `Task.return_`/`cancel` trap while it is non-zero; `lenders` is the + * caller-side `Subtask` (async-start-call) or a plain scope released when + * the caller's blocked frame gets its results (sync-start-call) — + * `lift_borrow` (line 1517) adds lenders there, released at + * `deliver_resolve` (line 904). Found by the #18 polymorph-tls smoke: the + * suite is the first corpus with borrow-carrying composed calls through + * prepare/start adapters, which previously asserted "transfer-borrow + * outside an enter-sync-call/exit-sync-call bracket". + */ +export interface FactStartScope { + /** The callee task (satisfies cabi's `TaskBorrowScope`). */ + taskScope: import("../cabi/context.ts").TaskBorrowScope; + /** The caller-side lender registrar (satisfies `SubtaskBorrowScope`). */ + lenders: { addLender(h: ResourceHandle): void }; +} + /** Executor services a trampoline body needs (provided by executor.ts). */ export interface TrampolineContext { componentInstance(index: number): ComponentInstanceState; @@ -231,6 +255,15 @@ export interface TrampolineContext { * owned by the executor so all trampolines of one instantiation share it. */ syncCallStack: SyncCallScope[]; + /** + * Stack of in-flight FACT `[async-start]` argument-copy windows (innermost + * last; see `FactStartScope`). Separate from `syncCallStack` because the + * prepare/start protocol has no enter/exit-sync-call bracket — the borrow + * bookkeeping attaches to the callee `Task` and the caller-side subtask + * instead (definitions.py `lower_borrow` line 1821 / `lift_borrow` line + * 1517). + */ + factStartScopes: FactStartScope[]; /** See `FactCallContext.calleeCanBlock` (intrinsics/fact_calls.ts). */ calleeCanBlock?(fn: unknown): boolean; /** See `HostTrapState`. */ @@ -744,11 +777,16 @@ function transferBorrow( const srcRt = ctx.resourceToken(srcTable); const dstRt = ctx.resourceToken(dstTable); + // Innermost-scope resolution. A FACT `[async-start]` copy window is + // strictly synchronous and innermost when present (the copy adapters + // cannot make nested calls), so it wins over any enclosing sync bracket. + const fact = ctx.factStartScopes[ctx.factStartScopes.length - 1]; const stack = syncScopes(ctx); const scope = stack[stack.length - 1]; assert_( - scope !== undefined, - "transfer-borrow outside an enter-sync-call/exit-sync-call bracket", + fact !== undefined || scope !== undefined, + "transfer-borrow outside an enter-sync-call/exit-sync-call bracket " + + "or FACT start window", ); const h = src.handles.get(handle); @@ -761,11 +799,12 @@ function transferBorrow( // definitions.py `lift_borrow`: the source handle becomes a lender of the // callee's activation, which is what makes lifting it as an `own` trap for // the duration of the call. - scope.addLender(rh); + (fact?.lenders ?? scope!).addLender(rh); // definitions.py `lower_borrow`: `if inst is t.rt.impl: return rep` — a // component that implements the resource is handed the rep directly and // gets no handle (and therefore no `num_borrows` obligation). if (dstRt.impl !== null && (dstRt.impl as unknown) === dst) return rh.rep; - scope.numBorrows += 1; - return dst.handles.add(new ResourceHandle(dstRt, rh.rep, false, scope)); + const borrowScope = fact !== undefined ? fact.taskScope : scope!; + borrowScope.numBorrows += 1; + return dst.handles.add(new ResourceHandle(dstRt, rh.rep, false, borrowScope)); } diff --git a/runtime/src/plan/loader.ts b/runtime/src/plan/loader.ts index 735a5c5..3621eb8 100644 --- a/runtime/src/plan/loader.ts +++ b/runtime/src/plan/loader.ts @@ -143,9 +143,32 @@ export function loadPlan(wire: WirePlan): LoadedPlan { } } - const resourceTokens = wire.resourceTables.map(() => - new ResourceTypeInfo(null, null) - ); + // Identity tokens: one per RESOURCE, aliased through every table that + // names it — NOT one per table. plan-format.md C2 amendment #1: "one + // resource type can be reachable through several distinct table indices … + // Consumers keying per-resource state must key by `resourceTables[n] + // .resource`, treating table indices as aliases." Minting per-table broke + // exactly the way that warning predicts (found by the #18 polymorph-tls + // smoke): in a wac-composed component the source and destination future + // tables of a FACT transfer resolve `own` through different table + // indices, and `valTypeEqual`'s documented reference-identity comparison + // (cabi/types.ts) saw two tokens for one resource — "future: destination + // element mismatch" on every resource-bearing element type. wasmtime + // interns identity at the `ResourceIndex` level and its transfer libcall + // never re-compares element types at runtime (47.0.3 + // futures_and_streams.rs `guest_transfer`); unifying here restores parity + // for every structural-equality site at once. Abstract tables keep + // per-table tokens (no `resource` to key by; none in the current corpus). + const tokenByResource = new Map(); + const resourceTokens = wire.resourceTables.map((table) => { + if (table.kind !== "concrete") return new ResourceTypeInfo(null, null); + let token = tokenByResource.get(table.resource); + if (token === undefined) { + token = new ResourceTypeInfo(null, null); + tokenByResource.set(table.resource, token); + } + return token; + }); const types = wire.types.map((t, i) => loadTypeDecl(t, resourceTokens, `types[${i}]`) ); diff --git a/runtime/src/task/streams.ts b/runtime/src/task/streams.ts index 0507416..b78502e 100644 --- a/runtime/src/task/streams.ts +++ b/runtime/src/task/streams.ts @@ -49,14 +49,17 @@ import { LiftLowerContext } from "../cabi/context.ts"; import { loadListFromValidRange } from "../cabi/load.ts"; import { storeListIntoValidRange } from "../cabi/store.ts"; import { alignTo, alignment, elemSize } from "../cabi/layout.ts"; -import { despecialize } from "../cabi/types.ts"; +import { despecialize, valTypeEqual } from "../cabi/types.ts"; import type { ComponentValue, ValType } from "../cabi/types.ts"; import { Waitable } from "./waitable.ts"; -/** Structural element-type equality (`null` = the zero-width payload). */ +/** Structural element-type equality (`null` = the zero-width payload). + * Delegates to `valTypeEqual`: naive `JSON.stringify` comparison throws on + * resource-bearing element types (cabi/types.ts `valTypeEqual` contract + * note; found by the #18 polymorph-tls smoke). */ export function sameElemType(a: ValType | null, b: ValType | null): boolean { if (a === null || b === null) return a === b; - return JSON.stringify(a) === JSON.stringify(b); + return valTypeEqual(a, b); } /** definitions.py `Buffer.MAX_LENGTH`. */ diff --git a/runtime/tests/fact_call_test.ts b/runtime/tests/fact_call_test.ts index 206d715..3e83614 100644 --- a/runtime/tests/fact_call_test.ts +++ b/runtime/tests/fact_call_test.ts @@ -74,6 +74,7 @@ function runPrepared(input: { memoryToken: () => null, stats: newStats(), prepared, + factStartScopes: [], }; const start = (...a: CoreValue[]) => { diff --git a/runtime/tests/tls_smoke_pins_test.ts b/runtime/tests/tls_smoke_pins_test.ts new file mode 100644 index 0000000..237b022 --- /dev/null +++ b/runtime/tests/tls_smoke_pins_test.ts @@ -0,0 +1,181 @@ +// Fail-on-pre-fix pins for the three runtime defects found by the #18 +// polymorph-tls conformance smoke (tools/smoke-tls/) — the first corpus +// with resource-bearing stream/future payloads and borrow-carrying calls +// through FACT prepare/start adapters. +// +// Pin 1 — structural ValType equality must not JSON.stringify types that +// contain `own`/`borrow` (ResourceTypeInfo cycles back to live +// instance state). Sibling of the C2-D `task.return` fix; hit +// again via `sameElemType` and lowerStream/lowerFuture's EAGERLY +// evaluated diagnostic strings. +// Pin 2 — one `ResourceTypeInfo` per component-wide ResourceIndex, aliased +// across resource tables (plan-format.md C2 amendment #1). Per- +// table tokens made FACT stream/future transfers trap "destination +// element mismatch" in wac-composed components. +// Pin 3 — `resource.transfer-borrow` inside a FACT `[async-start]` window +// (prepare/start protocol, no enter/exit-sync-call bracket): +// borrow bookkeeping attaches to the callee task + caller lender +// scope (definitions.py lift_borrow:1517 / lower_borrow:1821). + +import { + fmtValType, + ResourceTypeInfo, + Table, + valTypeEqual, + type ValType, +} from "../src/cabi/mod.ts"; +import { sameElemType } from "../src/task/streams.ts"; +import { loadPlan } from "../src/plan/mod.ts"; +import { + createTrampoline, + type FactStartScope, + SyncCallScope, + type TrampolineContext, +} from "../src/intrinsics/mod.ts"; +import { ResourceHandle } from "../src/cabi/handles.ts"; +import { SUPPORTED_FORMAT_VERSION } from "../src/plan/loader.ts"; +import type { WirePlan } from "../src/plan/format.ts"; +import { assertEq } from "./support/asserts.ts"; + +function minimalPlan(overrides: Partial = {}): WirePlan { + return { + formatVersion: SUPPORTED_FORMAT_VERSION, + producer: { shimVersion: "0", wasmtimeEnviron: "47.0.3", features: [] }, + component: { sha256: "0".repeat(64), len: 0 }, + modules: [], + initializers: [], + trampolines: [], + canonicalOptions: [], + types: [], + resourceTables: [], + imports: [], + exports: [], + worldDigest: "sha256:0", + ...overrides, + }; +} + +// --- Pin 1 ------------------------------------------------------------------ + +/** A resource type whose identity token cycles back to a table that holds a + * value referencing the type — the real shape: `rt.impl.handles` holds ends + * whose `.shared.t` contains the own type. `JSON.stringify` throws on it. */ +function cyclicResourceType(): { rt: ResourceTypeInfo; t: ValType } { + const handles = new Table(); + const impl = { handles, mayLeave: true }; + const rt = new ResourceTypeInfo(impl, null); + const t: ValType = { + kind: "future", + element: { kind: "result", ok: null, error: { kind: "own", rt } }, + }; + handles.add({ shared: { t } }); // closes the cycle + return { rt, t }; +} + +Deno.test("pin: sameElemType survives resource-bearing (cyclic) element types", () => { + const { rt, t } = cyclicResourceType(); + // Sanity: the pre-fix implementation (JSON.stringify comparison) throws here. + let threw = false; + try { + JSON.stringify(t); + } catch { + threw = true; + } + assertEq(threw, true, "fixture must actually be cyclic"); + + const same: ValType = { + kind: "future", + element: { kind: "result", ok: null, error: { kind: "own", rt } }, + }; + assertEq(sameElemType(t, same), true, "same rt -> equal"); + assertEq(valTypeEqual(t, same), true); + + const otherRt = new ResourceTypeInfo(null, null); + const different: ValType = { + kind: "future", + element: { kind: "result", ok: null, error: { kind: "own", rt: otherRt } }, + }; + assertEq(sameElemType(t, different), false, "distinct rt -> unequal, no throw"); +}); + +Deno.test("pin: fmtValType is cycle-safe and structural", () => { + const { t } = cyclicResourceType(); + assertEq(fmtValType(t), "future>>"); + assertEq(fmtValType(null), "_"); +}); + +// --- Pin 2 ------------------------------------------------------------------ + +Deno.test("pin: tables naming one ResourceIndex share one identity token", () => { + const loaded = loadPlan(minimalPlan({ + resourceTables: [ + { kind: "concrete", resource: 0, instance: 0 }, + { kind: "concrete", resource: 0, instance: 1 }, // alias (composed peer) + { kind: "concrete", resource: 1, instance: 0 }, + ], + })); + assertEq( + loaded.resourceTokens[0] === loaded.resourceTokens[1], + true, + "same ResourceIndex through two tables -> one token", + ); + assertEq( + loaded.resourceTokens[0] === loaded.resourceTokens[2], + false, + "distinct resources stay distinct", + ); +}); + +// --- Pin 3 ------------------------------------------------------------------ + +Deno.test("pin: transfer-borrow works inside a FACT [async-start] window", () => { + const srcInst = { handles: new Table(), mayLeave: true }; + const dstInst = { handles: new Table(), mayLeave: true }; + const srcRt = new ResourceTypeInfo(null, null); + const dstRt = new ResourceTypeInfo(null, null); // dst does NOT implement it + + const factStartScopes: FactStartScope[] = []; + const ctx = { + resourceTableInstance: (i: number) => (i === 0 ? srcInst : dstInst), + resourceToken: (i: number) => (i === 0 ? srcRt : dstRt), + syncCallStack: [] as SyncCallScope[], + factStartScopes, + trapState: { pending: null }, + } as unknown as TrampolineContext; + + const transfer = createTrampoline( + { kind: "resource-transfer-borrow" } as never, + ctx, + ); + + const handle = srcInst.handles.add(new ResourceHandle(srcRt, 17, true)); + + // Outside any scope: the pre-fix assertion (and still an error today). + let refused = false; + try { + transfer(handle, 0, 1); + } catch { + refused = true; + } + assertEq(refused, true, "no bracket, no FACT window -> refused"); + + // Inside a FACT start window: lender + callee-task bookkeeping, per the + // reference. Pre-fix this threw "transfer-borrow outside an + // enter-sync-call/exit-sync-call bracket". + const taskScope = { numBorrows: 0 }; + const lenders = new SyncCallScope(); + factStartScopes.push({ taskScope, lenders }); + const out = transfer(handle, 0, 1) as number; + factStartScopes.pop(); + + const src = srcInst.handles.get(handle) as ResourceHandle; + assertEq(src.numLends, 1, "source handle became a lender"); + assertEq(taskScope.numBorrows, 1, "callee task owes one borrow"); + const dst = dstInst.handles.get(out) as ResourceHandle; + assertEq(dst.own, false); + assertEq(dst.rep, 17); + assertEq(dst.borrowScope === taskScope, true, "drop decrements the callee task"); + + lenders.releaseLenders(); + assertEq(src.numLends, 0, "deliver-resolve releases the lender"); +}); diff --git a/tools/smoke-tls/run.ts b/tools/smoke-tls/run.ts new file mode 100644 index 0000000..316b4ad --- /dev/null +++ b/tools/smoke-tls/run.ts @@ -0,0 +1,199 @@ +// #18 — polymorph-tls: smoke the jco-leg surface under deltic. +// +// deno run --allow-read --allow-hrtime run.ts # translate-only +// deno run --allow-read run.ts --exec # + execute suites +// deno run --allow-read run.ts --exec --only SUBSTRING +// +// Mirrors tools/smoke-c0 leg 4 (translate-only + import-surface enumeration) +// and then goes one step further than the issue asks: the tls conformance +// suite artifacts are fully composed (no network, no sockets — recon +// 2026-08-09), so the same ct-runner + wasi-shims path that runs the +// websocket suite (ports/websocket/conformance/run.ts) can execute them +// directly. All consumer artifacts are referenced by absolute path and are +// READ-ONLY; nothing here writes to the polymorph trees. +// +// Named residues (conformance discipline: no unnamed absorption): +// TAG-GATING (#25) — their harness marks cases N/A per target via the L0 +// tags section (`missing`/`tagsOf`, run-node.mjs); ct-runner has no tag +// gating yet. Affected: `delegated/decline` (tagged !delegated-signer) +// fails on delegated compositions, and the plain composition fails its +// delegated-* cases. +// CALLBACK-NULL-CONTEXT (#24) — the webcrypto-composed target's +// `delegated/handshake` traps in wit-bindgen's async runtime +// (async_support.rs:578, callback with null context slot); scheduler- +// core defect, only reachable through that deeper composition. + +import { + fmtSurface, + loadTranslator, + planShape, + POLYMORPH, + readArtifact, + sha256Hex, + translateOnce, +} from "../smoke-c0/common.ts"; +import type { ComponentArtifacts } from "../../runtime/src/embedder/mod.ts"; +import { runSuite } from "../../ct-runner/src/mod.ts"; +import { wasiShims } from "../../wasi-shims/src/mod.ts"; +import { webcryptoImports } from "../../ports/webcrypto/src/mod.ts"; + +const CONF = `${POLYMORPH}/polymorph-tls/target/conformance`; + +/** Everything prebuilt in the consumer tree (recon 2026-08-09). */ +const TRANSLATE_TARGETS: Array<[string, string]> = [ + ["suite: plain (tls world, ed25519 only)", `${CONF}/suite-plain.wasm`], + ["suite: delegated (fixture signer plugged)", `${CONF}/suite-delegated.wasm`], + [ + "suite: delegated-webcrypto (webcrypto provider plugged)", + `${CONF}/suite-delegated-webcrypto.wasm`, + ], + ["bare: tls-plain (pre-fusion component)", `${CONF}/tls-plain.wasm`], + ["bare: tls-delegated", `${CONF}/tls-delegated.wasm`], + ["bare: tls-delegated-unwired (signer import open)", `${CONF}/tls-delegated-unwired.wasm`], + ["bare: tls-delegated-webcrypto", `${CONF}/tls-delegated-webcrypto.wasm`], + [ + "provider: webcrypto-signer-with-provider", + `${CONF}/webcrypto-signer-with-provider.wasm`, + ], +]; + +/** The executable smoke matrix: [target-key, artifact, xfails]. + * All compositions are self-contained (surfaces are pure WASI — phase 1), + * so no extra host modules are wired. Each xfail names its class + issue. */ +const EXEC_TARGETS: Array<[string, string, Record]> = [ + ["deltic-delegated", `${CONF}/suite-delegated.wasm`, { + "delegated/decline": "TAG-GATING #25 (!delegated-signer, N/A here)", + }], + ["deltic-delegated-webcrypto", `${CONF}/suite-delegated-webcrypto.wasm`, { + "delegated/decline": "TAG-GATING #25 (!delegated-signer, N/A here)", + "delegated/handshake": "CALLBACK-NULL-CONTEXT #24", + }], + // Plain composition: `delegated/decline` PASSES here (it asserts exactly + // the no-signer refusal) and coexist-in-guest-ed25519 needs no signer; + // only `delegated/handshake` is genuinely signer-gated. + ["deltic-plain", `${CONF}/suite-plain.wasm`, { + "delegated/handshake": "TAG-GATING #25 (delegated-signer, N/A on plain)", + }], +]; + +const CASE_TIMEOUT_MS = 60_000; // run-node.mjs's per-case wall bound. + +async function translatePhase(): Promise { + console.log("=== #18 polymorph-tls smoke, phase 1: translate-only ===\n"); + let failures = 0; + const t = await loadTranslator(); + for (const [label, path] of TRANSLATE_TARGETS) { + console.log(`--- ${label}`); + console.log(` ${path}`); + let bytes: Uint8Array; + try { + bytes = await readArtifact(path); + } catch (e) { + console.log(` SKIP — not present: ${e}`); + continue; + } + console.log(` bytes: ${bytes.length}`); + console.log(` sha256: ${await sha256Hex(bytes)}`); + const cold = translateOnce(t, bytes); + const warm: number[] = []; + for (let i = 0; i < 3; i++) warm.push(translateOnce(t, bytes).ms); + if (!cold.ok) { + failures++; + console.log(` VERDICT: REJECTED [${cold.errorPhase}]`); + console.log(` message: ${cold.errorMessage}`); + console.log(` detail: ${cold.errorDetail}`); + console.log(); + continue; + } + console.log( + ` VERDICT: ACCEPTED — cold ${cold.ms.toFixed(1)} ms, warm ${ + warm.map((w) => w.toFixed(1)).join("/") + } ms, envelope ${cold.envelopeBytes} B`, + ); + console.log(` ${planShape(cold.plan!)}`); + console.log(` IMPORT SURFACE:`); + console.log(fmtSurface(cold.plan!)); + console.log(); + } + return failures; +} + +async function execPhase(only?: string): Promise { + console.log("\n=== phase 2: execute the suites (ct-runner + wasi-shims) ===\n"); + const t = await loadTranslator(); + let failures = 0; + for (const [target, path, xfails] of EXEC_TARGETS) { + console.log(`--- ${target}: ${path}`); + let componentBytes: Uint8Array; + try { + componentBytes = await readArtifact(path); + } catch (e) { + console.log(` SKIP — not present: ${e}\n`); + continue; + } + const { plan, adapters } = t.translate(componentBytes); + const artifacts: ComponentArtifacts = { plan, componentBytes, adapters }; + const lines: string[] = []; + try { + const counts = await runSuite(artifacts, { + imports: wasiShims(), + target, + suiteName: path.split("/").pop()!.replace(/\.wasm$/, ""), + only, + caseTimeoutMs: CASE_TIMEOUT_MS, + emit: (line) => lines.push(line), + log: (msg) => console.error(` ${msg}`), + }); + console.log( + ` ${counts.passed} passed | ${counts.failed} failed | ` + + `${counts.skipped} skipped (${counts.total} total)`, + ); + // deltic-plain: delegated-* failures are the KNOWN tag-gating delta + // (header comment); anything else counts. + const failed = lines.map((l) => JSON.parse(l)).filter((o) => + o.case !== undefined && o.status === "fail" + ); + let unexpected = 0; + for (const o of failed) { + const xf = xfails[o.case as string]; + if (xf !== undefined) { + console.log(` XFAIL ${o.case}: ${xf}`); + } else { + unexpected++; + console.log(` FAIL ${o.case}: ${JSON.stringify(o)}`); + } + } + // Stale-xfail detection, same discipline as the harness: an xfail + // that PASSES must be pruned, not silently absorbed. + const failedNames = new Set(failed.map((o) => o.case as string)); + for (const name of Object.keys(xfails)) { + if (!failedNames.has(name) && (!only || name.includes(only))) { + unexpected++; + console.log(` STALE XFAIL ${name}: now passing — prune`); + } + } + if (unexpected > 0) { + failures++; + console.log(` VERDICT: FAIL`); + } else { + console.log(` VERDICT: PASS (${Object.keys(xfails).length} named xfails)`); + } + } catch (e) { + failures++; + console.log(` VERDICT: ERROR — ${e}`); + } + console.log(); + } + return failures; +} + +const exec = Deno.args.includes("--exec"); +const onlyIdx = Deno.args.indexOf("--only"); +const only = onlyIdx >= 0 ? Deno.args[onlyIdx + 1] : undefined; + +let failures = await translatePhase(); +if (exec) failures += await execPhase(only); +console.log( + `\nsmoke-tls verdict: ${failures === 0 ? "PASS" : `FAIL (${failures})`}`, +); +if (failures > 0) Deno.exit(1);