From a671eceb83098b574b4005e1b1d16a73d8fb87dc Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Mon, 14 Sep 2026 11:40:32 -0400 Subject: [PATCH 1/2] runtime: close out Wasmtime concurrency coverage gaps --- README.md | 3 +- contracts/intrinsics.md | 41 +- docs/architecture.md | 41 +- harness/README.md | 55 +- harness/browser/expectations/chromium.ts | 8 +- harness/browser/expectations/firefox.ts | 8 +- harness/shell/expectations/bun-pinned.ts | 8 +- harness/shell/expectations/jsc-pinned.ts | 8 +- harness/shell/expectations/jsc-trunk.ts | 8 +- harness/shell/expectations/node-pinned.ts | 11 +- harness/shell/expectations/sm-nightly.ts | 11 +- harness/shell/expectations/sm-pinned.ts | 8 +- harness/src/runner.ts | 17 + harness/src/wasmtime-expectations.ts | 117 +-- harness/src/wasmtime-spectest.ts | 22 +- harness/src/xfail.ts | 958 ++---------------- harness/tests/runner_unit_test.ts | 97 +- harness/tests/wasmtime_expectations_test.ts | 74 ++ justfile | 1 + runtime/src/cabi/trap.ts | 4 +- runtime/src/exec/boundary.ts | 240 ++++- runtime/src/exec/executor.ts | 29 +- runtime/src/intrinsics/async_builtins.ts | 17 +- runtime/src/intrinsics/fact_calls.ts | 25 +- runtime/src/intrinsics/mod.ts | 103 +- runtime/src/intrinsics/thread_builtins.ts | 314 ++++++ runtime/src/jspi/bridge.ts | 123 ++- runtime/src/plan/format.ts | 7 + runtime/src/task/mod.ts | 54 +- runtime/src/task/scheduler.ts | 242 ++++- runtime/src/task/thread.ts | 33 +- runtime/tests/boundary_trap_test.ts | 390 ++++++- runtime/tests/deferred_test.ts | 7 - runtime/tests/dtor_guest_context_test.ts | 4 - .../tests/enter_sync_call_reentrance_test.ts | 1 - runtime/tests/event_driven_drain_test.ts | 61 ++ .../tests/fixtures/thread-switch-matrix.wasm | Bin 0 -> 3086 bytes .../tests/fixtures/thread-switch-matrix.wat | 214 ++++ .../fixtures/thread-type-equivalence.wasm | Bin 0 -> 193 bytes .../fixtures/thread-type-equivalence.wat | 13 + runtime/tests/jspi/deadlock_test.ts | 4 +- .../tests/jspi/thread_switch_matrix_test.ts | 286 ++++++ runtime/tests/resource_identity_test.ts | 1 - .../subtask_cancel_sync_waiter_window_test.ts | 4 +- runtime/tests/sync_callee_progress_test.ts | 168 +++ runtime/tests/thread_builtins_test.ts | 321 ++++++ runtime/tests/tls_smoke_pins_test.ts | 1 - .../wasmtime/cancel_starting_reuse_test.ts | 96 ++ tools/shell/entry.ts | 24 +- tools/shell/host-node.mjs | 18 +- tools/shell/run-lane.ts | 92 +- tools/shell/run-lane_test.ts | 154 +++ tools/shell/tests/background-complete.mjs | 5 + 53 files changed, 3265 insertions(+), 1286 deletions(-) create mode 100644 runtime/src/intrinsics/thread_builtins.ts create mode 100644 runtime/tests/fixtures/thread-switch-matrix.wasm create mode 100644 runtime/tests/fixtures/thread-switch-matrix.wat create mode 100644 runtime/tests/fixtures/thread-type-equivalence.wasm create mode 100644 runtime/tests/fixtures/thread-type-equivalence.wat create mode 100644 runtime/tests/jspi/thread_switch_matrix_test.ts create mode 100644 runtime/tests/sync_callee_progress_test.ts create mode 100644 runtime/tests/thread_builtins_test.ts create mode 100644 runtime/tests/wasmtime/cancel_starting_reuse_test.ts create mode 100644 tools/shell/run-lane_test.ts create mode 100644 tools/shell/tests/background-complete.mjs diff --git a/README.md b/README.md index d2b82e6e..6de8a0cc 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ the applicable gate. sync and async, including composed components and overlapping exports. - Runtime coverage includes canonical ABI values, resources, async host imports, streams/futures, and background progress between export calls. -- Known gaps include deferred thread features, upstream-unimplemented +- Known gaps include the non-final/derived `thread.new-indirect` signature + restriction, upstream-unimplemented features, and sync scheduling gaps. See [architecture §11](docs/architecture.md#11-conformance-and-testing) and the [issue tracker](https://github.com/polymorph-components/polyengine/issues). diff --git a/contracts/intrinsics.md b/contracts/intrinsics.md index 9b03d453..58e1911d 100644 --- a/contracts/intrinsics.md +++ b/contracts/intrinsics.md @@ -19,11 +19,12 @@ semantics remain governed by the pinned spec and `definitions.py`, subject to [architecture §5](../docs/architecture.md#5-the-jspi-frame-rule-load-bearing-constraint). 2. **Trap and capability failures differ.** Guest violations raise `Trap`. Unsupported operations raise capability errors and must not satisfy - conformance trap assertions. Component Model traps must be uncatchable, but - this runtime's JS exceptions do not fully provide that guarantee: a guest - `try_table catch_all` can catch a host trap. Adapter exception barriers - preserve the original diagnostic through `HostTrapState`; they do not - eliminate the limitation. + conformance trap assertions. Guest-facing trampoline failures cross Wasm as + native traps so `try_table catch_all` cannot intercept them; the runtime + binds each carrier identity to its semantic cause and originating physical + and logical activations, preserving both through nested barriers before + restoring the cause at the component boundary. Raw core-Wasm exceptions + escaping a canonical lift likewise become Component Model traps. 3. **Instance invariants are runtime obligations.** JSPI does not enforce `may_leave`, borrow scopes, or task exclusivity. Reentrance into a live instance is valid. Entry refusal is the runtime's per-instance poisoning @@ -82,15 +83,35 @@ directly. `modules[].intrinsics` records import names and resolved categories. Implemented groups are host import lowering; resource new/rep/drop and transfer; transcoding; backpressure; task return/cancel; waitable sets and join; subtask -drop/cancel; stream/future operations; error contexts; context get/set; and -thread yield. Other explicit thread builtins are representable in the plan but -unsupported by the runtime. +drop/cancel; stream/future operations; error contexts; context get/set; and the +explicit-thread family: `thread.index`, `thread.new-indirect`, +`thread.resume-later`, `thread.suspend`, `thread.yield`, and the +`suspend`/`yield`-then-`resume`/`promote` forms. Explicit threads use the same +task scheduler and JSPI activation bridge as the implicit thread. Publishing a +task result does not destroy its remaining explicit threads; the worker owns +host-call teardown after result delivery. + +`thread.new-indirect` currently accepts the canonical final `(i32) -> ()` and +`(i64) -> ()` start-function types. Its native `ref.test` validator rejects +some functions whose valid non-final/derived type is structurally equivalent +but has a different nominal reference identity. This is a runtime interface +capability restriction, not a Component Model validation rule and not a claim +that such guests are invalid. The JavaScript WebAssembly API exposes neither +function-signature reflection nor the reference-type relation needed to decide +the general case without calling the function (which `thread.new-indirect` +must not do during validation); native Wasmtime has a similar current +restriction but is corroborating evidence only. Full support requires +translator-supplied core-function metadata or table instrumentation/type +normalization and remains tracked by +[#12](https://github.com/polymorph-components/polyengine/issues/12). Trampolines are materialized on first reference during instantiation. Unsupported referenced kinds fail then with a capability diagnostic; unused entries do not prevent instantiation. A supported blocking operation may still -require JSPI at call time. The runtime's `createTrampoline` switch is the -current implementation inventory; [plan-format.md](plan-format.md) defines the +require JSPI at call time. The inventory above is the explicit supported +surface; it is not a claim of unrestricted thread conformance because of the +`thread.new-indirect` type restriction. The runtime's `createTrampoline` switch +is the implementation inventory; [plan-format.md](plan-format.md) defines the wire representation. ## Manifest diff --git a/docs/architecture.md b/docs/architecture.md index 12e1cde8..d50238e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -252,6 +252,7 @@ progress. There is no preemption. | Resume | Scheduler resolves or consumes the relevant settlement | | Callback ABI | Scheduler invokes the callback export with events; no suspended wasm stack | | Waitable / waitable set | Host-side event state, consumed by stackful waits or callback return codes | +| Explicit cooperative threads | Task-owned logical threads; JSPI suspension points carry stackful parks and named direct switches | | Sync `canon_lift` | Drive the task to resolution, retaining the reference's deadlock trap | | Async `canon_lift` | Exit the driver on idle; an unresolved export Promise stays pending for later progress | @@ -298,6 +299,16 @@ acts. An idle async-typed export may remain pending indefinitely; sync-typed exports retain deadlock detection. See the [function contract](../contracts/embedder-api.md#functions-and-async). +**Synchronous boundary and host latency.** A synchronous logical canonical +callee that reaches a Component Model park drives only work belonging to that +callee instance and traps when none can progress, as required by the reference +`canon_lift` loop. A Promise returned by a host import marked `suspending()` is +an embedding accommodation: its latency is not itself Component Model blocking +or proof that unrelated Component Model work can progress. Component traps and +escaping core Wasm exceptions cross guest Wasm via an uncatchable native trap +carrier keyed to the exact physical activation and semantic cause; there is no +reusable global cause slot. + **Host-import cancellation.** By default, cancellation resolves the subtask promptly as `CANCELLED_BEFORE_RETURNED` and discards late Promise settlements. The result is not lowered, and the discarded call no longer @@ -409,8 +420,9 @@ the plan; see [descriptor IR](../contracts/descriptor-ir.md#resource-type-identi destructor with synchronous canonical options. The destructor may not Component-Model-block, though the spec permits spawning an explicit thread that blocks without preventing the destructor's implicit thread -from returning. This does not imply support for the deferred explicit-thread -built-ins (§11). Both guest- and host-initiated drops of guest resources use +from returning. Such explicit threads use the scheduler described in §6 and +are not destroyed merely because the destructor result has returned. Both +guest- and host-initiated drops of guest resources use `createDtorEntry` in `runtime/src/exec/boundary.ts`, creating a fresh synchronous task and implicit thread rather than borrowing the caller's task. A missing destructor still goes through that lift machinery. @@ -573,9 +585,8 @@ normalization belongs to `TRAP_MESSAGE_EQUIVALENTS` in `harness/src/runner.ts`, not the runtime. Expected failures are classified, not counted as conformance. Known -classes include deferred thread support -([#12](https://github.com/polymorph-components/polyengine/issues/12)), -sync scheduling gaps +classes include the explicit-thread type-interface restriction +([#12](https://github.com/polymorph-components/polyengine/issues/12)), sync scheduling gaps ([#249](https://github.com/polymorph-components/polyengine/issues/249)), and upstream-unimplemented features ([#248](https://github.com/polymorph-components/polyengine/issues/248)). @@ -583,6 +594,26 @@ Per-lane overlays distinguish engine limitations from runtime failures. The current base classification is in [harness/src/xfail.ts](../harness/src/xfail.ts). Unexpected failures and stale expected failures fail their gate. +The current Deno and Chromium official-corpus baseline executes 1,506 of +1,511 commands: 1,468 pass, 38 are exact xfails, and five text directives are +unsupported. There are no runtime/capability skips. The corpus exercises the +implemented explicit-thread family using canonical-final start signatures; it +does not exercise the valid non-final/derived signature restriction described +in the intrinsic contract, so these counts are not a full-thread-conformance +claim. + +The supplementary Wasmtime lane adapts native test controls only when their +semantic observation survives the adaptation. Its `gc` helper for the resource +destructor context test is a real synchronous host boundary: Wasmtime uses that +call to force a deferred frame, while this runtime eagerly materializes the +corresponding logical task/thread. No JavaScript GC is forced. Wasmtime's +table-capacity control is not exposed as a production option; a focused runtime +test instead checks bounded handle reuse across the same 1,000 cancelled +STARTING subtasks. The excluded `streams-massive-send` file asserts Wasmtime's +host-defined 128 MiB transfer-fuel policy over an exponentially expanded value, +not a Component Model limit; the exclusion does not imply that polyengine has +an equivalent aggregate transfer budget. + The [justfile](../justfile) is the command surface; CI job bodies live in [.github/justfile](../.github/justfile). Required PR checks use the pinned shell lanes alongside core tests. Browser lanes run post-merge and gate diff --git a/harness/README.md b/harness/README.md index 0142a49b..9101f8c9 100644 --- a/harness/README.md +++ b/harness/README.md @@ -97,13 +97,58 @@ and their tracking issues are declared in to [#372](https://github.com/polymorph-components/polyengine/issues/372) (runtime-semantics, diagnostic-mismatch, imported-module, cascade, provider-control, exception-handling — plan v0 / diagnostic gaps against -Wasmtime's own assertions, not the spec corpus above). `deferred-threads` -skips map to [#12](https://github.com/polymorph-components/polyengine/issues/12). -A handful of files are excluded outright (unbounded memory stress, GC, or -Wasmtime-specific validation configuration this translator doesn't share) — -see `WASMTIME_EXCLUSIONS` for the current list and reasons; the run fails if an +Wasmtime's own assertions, not the spec corpus above). The supplementary thread +fixtures now execute rather than being skipped. This does not imply +unrestricted explicit-thread conformance: valid non-final or derived +start-function signatures can be rejected by the runtime's nominal `ref.test` +validator, as documented in `contracts/intrinsics.md`; the current Wasmtime +fixtures use the supported canonical-final signatures and do not test that +interface restriction +([#12](https://github.com/polymorph-components/polyengine/issues/12)). +A handful of files are excluded outright; see `WASMTIME_EXCLUSIONS` for the +current list and reasons. In particular, `streams-massive-send.wast` asserts +Wasmtime's host-defined 128 MiB per-hostcall transfer-fuel policy using an +exponentially expanded nested-list value. The Component Model specifies no such +fuel limit, and executing it in this in-process V8 harness can exhaust the +bounded heap before a catchable result. This exclusion is not a claim that the +runtime has an equivalent production resource limit. The run fails if an exclusion goes stale (the file gone from the manifest). +Two Wasmtime-private controls are treated by purpose rather than name. The +`context-in-resource-drop.wast` `wasmtime/gc` import is supplied only for that +file as a real synchronous host call. Wasmtime uses GC to force a deferred +destructor frame; polyengine eagerly materializes the logical task/thread, so +the boundary itself exercises context preservation and no JavaScript GC is +forced. Conversely, `set-max-table-capacity` is not emulated: its leak-detection +purpose is covered by +`runtime/tests/wasmtime/cancel_starting_reuse_test.ts`, which performs 1,000 +STARTING-cancel-deliver-drop cycles and asserts bounded handle-slot reuse. + +Some exact supplementary trap strings describe the same rejected operation at +different levels of detail. `runner.ts` records exact-only equivalents for the +five non-thread `task-return-traps.wast` diagnostic rows. Eleven future-write +rows remain classified instead: in each named fixture the reader was dropped, +but Wasmtime retains separate local/transmit completion guards while +polyengine's reference-shaped `WritableFutureEnd` has one `CopyState.DONE` and +reports its broader “previous write succeeded or readable end dropped” text. +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. + +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. +The capacity knob is intentionally not a production capability; the bounded +reuse test above covers its leak-detection purpose without pretending to +reproduce Wasmtime's host configuration surface. + +The official Deno/Chromium aggregate at the current Component Model pin is +1,511 commands, 1,506 executed, 1,468 passed, 38 exact xfails, zero +runtime/capability skips, and five unsupported text directives. Seeded runs +omit the three-command `async-calls-sync` deterministic-profile fixture, giving +1,508 commands, 1,503 executed, and 1,465 passed with the same 38 xfails and +five unsupported directives. + `just test-wasmtime-guests` builds the two upstream async guest binaries this WAST corpus doesn't cover as executables — `async_round_trip_stackless` and `async_short_reads` from `crates/test-programs/src/bin/` at the same locked diff --git a/harness/browser/expectations/chromium.ts b/harness/browser/expectations/chromium.ts index ace3ad9a..29128451 100644 --- a/harness/browser/expectations/chromium.ts +++ b/harness/browser/expectations/chromium.ts @@ -16,11 +16,11 @@ export const chromium: LaneExpectation = { // Identical to the Deno lane's TOTAL row. totals: { commands: 1511, - executed: 1411, - passed: 1286, + executed: 1506, + passed: 1468, failed: 0, - xfail: 125, - pendingRuntime: 95, + xfail: 38, + pendingRuntime: 0, pendingCapability: 0, unsupportedDirective: 5, }, diff --git a/harness/browser/expectations/firefox.ts b/harness/browser/expectations/firefox.ts index d94052d6..f6dd1764 100644 --- a/harness/browser/expectations/firefox.ts +++ b/harness/browser/expectations/firefox.ts @@ -20,11 +20,11 @@ export const firefox: LaneExpectation = { // does not gate on them (`required: false`). totals: { commands: 1511, - executed: 1411, - passed: 1286, + executed: 1506, + passed: 1468, failed: 0, - xfail: 125, - pendingRuntime: 95, + xfail: 38, + pendingRuntime: 0, pendingCapability: 0, unsupportedDirective: 5, }, diff --git a/harness/shell/expectations/bun-pinned.ts b/harness/shell/expectations/bun-pinned.ts index 8560a1e6..7e3782de 100644 --- a/harness/shell/expectations/bun-pinned.ts +++ b/harness/shell/expectations/bun-pinned.ts @@ -44,11 +44,11 @@ export const bunPinned: ShellLaneExpectation = { deltas: [], totals: { commands: 1511, - executed: 1411, - passed: 1286, + executed: 1506, + passed: 1468, failed: 0, - xfail: 125, - pendingRuntime: 95, + xfail: 38, + pendingRuntime: 0, pendingCapability: 0, unsupportedDirective: 5, }, diff --git a/harness/shell/expectations/jsc-pinned.ts b/harness/shell/expectations/jsc-pinned.ts index dd1dd3df..0d7f9989 100644 --- a/harness/shell/expectations/jsc-pinned.ts +++ b/harness/shell/expectations/jsc-pinned.ts @@ -20,11 +20,11 @@ export const jscPinned: ShellLaneExpectation = { deltas: [], totals: { commands: 1511, - executed: 1411, - passed: 1286, + executed: 1506, + passed: 1468, failed: 0, - xfail: 125, - pendingRuntime: 95, + xfail: 38, + pendingRuntime: 0, pendingCapability: 0, unsupportedDirective: 5, }, diff --git a/harness/shell/expectations/jsc-trunk.ts b/harness/shell/expectations/jsc-trunk.ts index 17e0d221..df30c8a5 100644 --- a/harness/shell/expectations/jsc-trunk.ts +++ b/harness/shell/expectations/jsc-trunk.ts @@ -54,11 +54,11 @@ export const jscTrunk: ShellLaneExpectation = { deltas: [], totals: { commands: 1511, - executed: 1411, - passed: 1286, + executed: 1506, + passed: 1468, failed: 0, - xfail: 125, - pendingRuntime: 95, + xfail: 38, + pendingRuntime: 0, pendingCapability: 0, unsupportedDirective: 5, }, diff --git a/harness/shell/expectations/node-pinned.ts b/harness/shell/expectations/node-pinned.ts index 48b788dc..5b0b950d 100644 --- a/harness/shell/expectations/node-pinned.ts +++ b/harness/shell/expectations/node-pinned.ts @@ -25,8 +25,7 @@ import type { ShellLaneExpectation } from "./types.ts"; export const nodePinned: ShellLaneExpectation = { lane: "node-pinned", required: true, - notes: - "Node.js pinned (v26.7.0, nodejs.org tarball, sha256-verified, both " + + notes: "Node.js pinned (v26.7.0, nodejs.org tarball, sha256-verified, both " + "arches). Exact Deno-lane parity with no flags (JSPI default-on in " + ">= 26): zero deltas, all capabilities true. Required gate. Node 24 LTS " + "is deliberately not laned — flag-gated JSPI with 2 real deviations " + @@ -34,11 +33,11 @@ export const nodePinned: ShellLaneExpectation = { deltas: [], totals: { commands: 1511, - executed: 1411, - passed: 1286, + executed: 1506, + passed: 1468, failed: 0, - xfail: 125, - pendingRuntime: 95, + xfail: 38, + pendingRuntime: 0, pendingCapability: 0, unsupportedDirective: 5, }, diff --git a/harness/shell/expectations/sm-nightly.ts b/harness/shell/expectations/sm-nightly.ts index 35e4bd91..ff355179 100644 --- a/harness/shell/expectations/sm-nightly.ts +++ b/harness/shell/expectations/sm-nightly.ts @@ -36,18 +36,17 @@ import type { ShellLaneExpectation } from "./types.ts"; export const smNightly: ShellLaneExpectation = { lane: "sm-nightly", required: false, - notes: - "SpiderMonkey nightly (linux-aarch64 jsshell). Full Deno parity: " + + notes: "SpiderMonkey nightly (linux-aarch64 jsshell). Full Deno parity: " + "zero deltas, all compile-probes true (multi-memory/wasm-GC/EH/memory64/" + "tail-calls/relaxed-simd), JSPI round trip verified end to end.", deltas: [], totals: { commands: 1511, - executed: 1411, - passed: 1286, + executed: 1506, + passed: 1468, failed: 0, - xfail: 125, - pendingRuntime: 95, + xfail: 38, + pendingRuntime: 0, pendingCapability: 0, unsupportedDirective: 5, }, diff --git a/harness/shell/expectations/sm-pinned.ts b/harness/shell/expectations/sm-pinned.ts index fbe16b10..3728e98c 100644 --- a/harness/shell/expectations/sm-pinned.ts +++ b/harness/shell/expectations/sm-pinned.ts @@ -23,11 +23,11 @@ export const smPinned: ShellLaneExpectation = { deltas: [], totals: { commands: 1511, - executed: 1411, - passed: 1286, + executed: 1506, + passed: 1468, failed: 0, - xfail: 125, - pendingRuntime: 95, + xfail: 38, + pendingRuntime: 0, pendingCapability: 0, unsupportedDirective: 5, }, diff --git a/harness/src/runner.ts b/harness/src/runner.ts index 150440c1..a40f2f9d 100644 --- a/harness/src/runner.ts +++ b/harness/src/runner.ts @@ -349,6 +349,7 @@ const TRAP_MESSAGE_EQUIVALENTS: Array< "guest trapped: unreachable", "guest trapped: unreachable executed", "guest trapped: Unreachable code should not be executed", + "guest trapped: Unreachable code should not be executed (evaluating 'fn()')", "guest trapped: Unreachable code should not be executed (evaluating 'fn(...args)')", ], ], @@ -363,6 +364,7 @@ const TRAP_MESSAGE_EQUIVALENTS: Array< "unreachable", [ "guest trapped: Unreachable code should not be executed", + "guest trapped: Unreachable code should not be executed (evaluating 'fn()')", "guest trapped: Unreachable code should not be executed (evaluating 'fn(...args)')", ], ], @@ -376,6 +378,7 @@ const TRAP_MESSAGE_EQUIVALENTS: Array< "guest trapped: unreachable", "guest trapped: unreachable executed", "guest trapped: Unreachable code should not be executed", + "guest trapped: Unreachable code should not be executed (evaluating 'fn()')", "guest trapped: Unreachable code should not be executed (evaluating 'fn(...args)')", ], ], @@ -416,6 +419,20 @@ const TRAP_MESSAGE_EQUIVALENTS: Array< "cannot write after being notified that the readable end dropped", ["cannot write to stream after being notified that the readable end dropped"], ], + // Pinned Wasmtime task-return-traps.wast uses these umbrella diagnostics. + // The runtime reports the precise reference-state check which fired. Each + // pair is exact so unrelated task lifecycle failures remain mismatches. + [ + "async-lifted export failed to produce a result", + ["task finished all threads without resolving"], + ], + [ + "invalid `task.return` signature and/or options for current task", + [ + "task.return with a result type that is not the task's result type", + "task.return with canonical options differing from the task's", + ], + ], // The same-instance non-numeric guard is the exact check on both sides: // runtime/src/task/streams.ts:587-591,708-711,725-728 and pinned Wasmtime // futures_and_streams.rs:3330-3335. Future/stream names are diagnostic only. diff --git a/harness/src/wasmtime-expectations.ts b/harness/src/wasmtime-expectations.ts index 83784446..a2d374e2 100644 --- a/harness/src/wasmtime-expectations.ts +++ b/harness/src/wasmtime-expectations.ts @@ -51,10 +51,6 @@ export const WASMTIME_FAILURE_CLASSES: Readonly< reason: "command has no current instance after its owning setup failure", issue: "https://github.com/polymorph-components/polyengine/issues/372", }, - "deferred-threads": { - reason: "deferred thread.new-indirect support is not implemented", - issue: "https://github.com/polymorph-components/polyengine/issues/12", - }, }; export const WASMTIME_EXPECTATION_GROUPS: readonly WasmtimeExpectationGroup[] = @@ -78,52 +74,11 @@ export const WASMTIME_EXPECTATION_GROUPS: readonly WasmtimeExpectationGroup[] = status: "failed", }], }, - { - file: "async/context-in-resource-drop.json", - rows: [{ - lines: [325, 326, 327, 328], - cause: "Error: no current instance", - status: "failed", - }], - }, - { - file: "async/join-during-sync-read.json", - rows: [{ - lines: [66], - cause: "Error: no current instance", - status: "failed", - }], - }, - { - file: "async/task-deletion.json", - rows: [{ - lines: [323, 324, 325, 326, 327, 328, 329, 330, 331], - cause: "Error: no current instance", - status: "failed", - }], - }, - { - file: "async/task-return-traps.json", - rows: [{ - lines: [56, 91], - cause: "Error: no current instance", - status: "failed", - }], - }, ], }, { class: "diagnostic-mismatch", files: [ - { - file: "async/future-read.json", - rows: [{ - lines: [65], - cause: - 'Error: expected trap "wasm trap: cannot block a synchronous task before returning", got "wasm trap: deadlock detected: event loop cannot make further progress (export \'run\': no runnable work or host call is outstanding)"', - status: "failed", - }], - }, { file: "async/stream-cancel-finished-op.json", rows: [{ @@ -133,25 +88,6 @@ export const WASMTIME_EXPECTATION_GROUPS: readonly WasmtimeExpectationGroup[] = status: "failed", }], }, - { - file: "async/task-return-traps.json", - rows: [{ - lines: [19, 104], - cause: - 'Error: expected trap "async-lifted export failed to produce a result", got "task finished all threads without resolving"', - status: "failed", - }, { - lines: [118], - cause: - 'Error: expected trap "invalid `task.return` signature and/or options for current task", got "task.return with a result type that is not the task\'s result type"', - status: "failed", - }, { - lines: [135, 150], - cause: - 'Error: expected trap "invalid `task.return` signature and/or options for current task", got "task.return with canonical options differing from the task\'s"', - status: "failed", - }], - }, { file: "async/trap-if-done.json", rows: [{ @@ -161,15 +97,6 @@ export const WASMTIME_EXPECTATION_GROUPS: readonly WasmtimeExpectationGroup[] = status: "failed", }], }, - { - file: "exceptions.json", - rows: [{ - lines: [50, 127, 161, 237], - cause: - 'Error: expected trap "uncaught exception propagated out of component", got "guest trapped: unreachable"', - status: "failed", - }], - }, { file: "resources.json", rows: [{ @@ -199,24 +126,6 @@ export const WASMTIME_EXPECTATION_GROUPS: readonly WasmtimeExpectationGroup[] = }, ], }, - { - class: "exception-handling", - files: [ - { - file: "async/exceptions.json", - rows: [{ - lines: [68, 70, 144, 146, 216], - cause: "[object WebAssembly.Exception]", - status: "failed", - }, { - lines: [218], - cause: - 'Error: expected trap "thrown Wasm exception", got "guest trapped: unreachable"', - status: "failed", - }], - }, - ], - }, { class: "imported-module", files: [ @@ -270,15 +179,6 @@ export const WASMTIME_EXPECTATION_GROUPS: readonly WasmtimeExpectationGroup[] = status: "failed", }], }, - { - file: "async/context-in-resource-drop.json", - rows: [{ - lines: [248], - cause: - "PlanError: host import 'wasmtime/gc' not provided (no key 'wasmtime' in imports)", - status: "failed", - }], - }, { file: "instance.json", rows: [{ @@ -420,22 +320,7 @@ export const WASMTIME_EXPECTATIONS: readonly WasmtimeExpectation[] = ) ); -export const WASMTIME_SKIP_EXPECTATIONS: readonly WasmtimeExpectation[] = [ - "async/join-during-sync-read.json:8", - "async/task-deletion.json:11", - "async/task-return-traps.json:21", - "async/task-return-traps.json:58", -].map((key) => { - const split = key.lastIndexOf(":"); - return { - file: key.slice(0, split), - line: Number(key.slice(split + 1)), - status: "skipped" as const, - class: "deferred-threads", - cause: - "pending component runtime: pending-capability: instantiate: component requires host trampoline 'thread-new-indirect' — needs the \"task-core\" capability, not yet implemented in the current executor (contracts/intrinsics.md §B)", - }; -}); +export const WASMTIME_SKIP_EXPECTATIONS: readonly WasmtimeExpectation[] = []; export const WASMTIME_EXCLUSIONS: Readonly> = { "big-strings.json": diff --git a/harness/src/wasmtime-spectest.ts b/harness/src/wasmtime-spectest.ts index 582409da..384125aa 100644 --- a/harness/src/wasmtime-spectest.ts +++ b/harness/src/wasmtime-spectest.ts @@ -6,14 +6,18 @@ import { export interface SpectestProbe { readonly imports: HostImports; - readonly counters: { readonly drops: number; readonly lastDrop: number }; + readonly counters: { + readonly drops: number; + readonly lastDrop: number; + readonly forcedHostBoundaries: number; + }; } /** Port of locked wasmtime crates/wast/src/spectest.rs:90-224. * Raw HostImports expose reps but not Wasmtime's `Resource::owned()` bit, so * those upstream ownership assertions are not duplicated here. */ export function wasmtimeSpectest(sourceFile = ""): SpectestProbe { - const state = { drops: 0, lastDrop: 0 }; + const state = { drops: 0, lastDrop: 0, forcedHostBoundaries: 0 }; const resource1 = hostResourceType({ name: "host.resource1", dtor: (rep) => { @@ -24,7 +28,7 @@ export function wasmtimeSpectest(sourceFile = ""): SpectestProbe { return { counters: state, imports: { - "host-echo-u32": async (v: unknown) => v, + "host-echo-u32": (v: unknown) => Promise.resolve(v), "host-return-two": () => 2, host: { "return-three": () => 3, @@ -68,6 +72,18 @@ export function wasmtimeSpectest(sourceFile = ""): SpectestProbe { ), "return-hi": () => "hi", }, + ...(sourceFile === "async/context-in-resource-drop.json" + ? { + // Wasmtime's GC forces a deferred destructor frame. Polyengine has + // already materialized that logical Task/Thread; this real host call + // preserves the boundary observation without forcing JavaScript GC. + wasmtime: { + gc: () => { + state.forcedHostBoundaries++; + }, + }, + } + : {}), }, }; } diff --git a/harness/src/xfail.ts b/harness/src/xfail.ts index c0c3d860..5af02de8 100644 --- a/harness/src/xfail.ts +++ b/harness/src/xfail.ts @@ -1,940 +1,96 @@ -// Checked-in triage list: commands known to fail against the current -// runtime, with a reason, so the conformance run's summary distinguishes -// "known, triaged failure" (xfail) from "unexpected regression" (failed). -// Keyed by `{file, line}` — `line` is the command's 1-based source line -// from testgen's JSON (stable across regen: same suite source -> same -// line), which uniquely identifies a command within a file. -// -// Entries here are removed as the runtime gains the capability that makes -// them pass; an xfail entry whose command now PASSES fails the run loudly -// (the stale-xfail detector in tests/conformance_test.ts) — -// prune stale entries rather than accumulating masks. +// Checked-in triage list for commands known to fail against the current +// runtime. Passing entries are stale and fail the conformance gate. export interface XfailEntry { - /** relative path under harness/generated/, e.g. "linking/unit.json". */ file: string; - /** 1-based source line (`Command.line`) of the failing command. */ line: number; reason: string; } +const issue248 = + "https://github.com/polymorph-components/polyengine/issues/248"; +const issue249 = + "https://github.com/polymorph-components/polyengine/issues/249"; + export const XFAIL: XfailEntry[] = [ - // (The former `wasmparser/wast pin drift` class, polyengine#152, exited - // with the wasmtime `main` re-pin: testgen's `wast` and the shim's - // wasmparser are on the same release train, enforced by `just test-rust`.) - // --- validation/kebab.json: CM#703/#704 ("name rules" reworks, pulled in - // by the CM#705 pin advance polyengine#173) added import-name-conflict - // checks under kebab-case folding (a `foo-bar` import conflicts with - // `foobar`/`FOOBAR`/`foob-ar`/method-and-static-qualified variants that - // fold to the same name). This is not pin-drift residue: the file is - // listed in upstream's own third_party/component-model/test/nyi.txt at - // the current pin (wasmtime `main`@4675ee1) — wasmtime itself does not - // implement this check yet, so wasmparser 0.258 accepts all five - // components as distinct imports. Classed `name-rules-nyi`, - // https://github.com/polymorph-components/polyengine/issues/248 (pending-capability: upstream nyi.txt). - // Same gap class already tracked for interface names in - // upstream-component-model-repo-findings.md (#246/#247). --- - { - file: "validation/kebab.json", - line: 150, - reason: - 'expected assert_invalid ("import name `foobar` conflicts with ' + - 'previous name `foo-bar`"), but it validated — wasmtime does not ' + - "implement CM#703/#704's kebab-case name-folding conflict check yet " + - "(third_party/component-model/test/nyi.txt lists this file); " + - "name-rules-nyi, https://github.com/polymorph-components/polyengine/issues/248 (pending-capability: upstream " + - "nyi.txt)", - }, - { + // The pinned Wasmtime frontend does not yet implement the Component Model + // name-folding conflicts (CM#703/#704); test/nyi.txt lists this file. + ...[ + [150, "`foobar` conflicts with `foo-bar`"], + [155, "`FOOBAR` conflicts with `foo-bar`"], + [160, "`foob-ar` conflicts with `foo-bar`"], + [165, "the static-qualified folded name conflicts"], + [170, "the method-qualified folded name conflicts"], + ].map(([line, detail]) => ({ file: "validation/kebab.json", - line: 155, + line: line as number, reason: - 'expected assert_invalid ("import name `FOOBAR` conflicts with ' + - 'previous name `foo-bar`"), but it validated — same name-rules-nyi ' + - "gap as line 150, https://github.com/polymorph-components/polyengine/issues/248", - }, - { - file: "validation/kebab.json", - line: 160, - reason: - 'expected assert_invalid ("import name `foob-ar` conflicts with ' + - 'previous name `foo-bar`"), but it validated — same name-rules-nyi ' + - "gap as line 150, https://github.com/polymorph-components/polyengine/issues/248", - }, - { - file: "validation/kebab.json", - line: 165, - reason: - 'expected assert_invalid ("import name `[static]foo-bar.FO-ob-AR` ' + - 'conflicts with previous name `foo-bar`"), but it validated — same ' + - "name-rules-nyi gap as line 150, https://github.com/polymorph-components/polyengine/issues/248", - }, - { - file: "validation/kebab.json", - line: 170, - reason: - 'expected assert_invalid ("import name `[method]foo-bar.foobar` ' + - 'conflicts with previous name `foo-bar`"), but it validated — same ' + - "name-rules-nyi gap as line 150, https://github.com/polymorph-components/polyengine/issues/248", - }, - // --- validation/max-value-size.json: CM#688 ("max-value-size", pulled in - // by the CM#705 pin advance polyengine#173) added the elem_size(t, i64) < - // 2^28 validation rule (CanonicalABI.md#element-size). Not pin-drift - // residue: this file is also listed in upstream's own - // third_party/component-model/test/nyi.txt at the current pin — wasmtime - // itself does not enforce this check yet, so every assert_invalid in - // this file validates instead of rejecting. Classed `max-value-size-nyi`, - // https://github.com/polymorph-components/polyengine/issues/248 (pending-capability: upstream nyi.txt). - // Line 63 is the dispatch-flagged pointer-width-sensitive row - // (`list string 16777216`, the i32-vs-i64 elem-size boundary): observed - // behavior on this (presumably 64-bit host) run is identical to the - // others — wasmparser accepts it outright, not a differing failure mode - // tied to pointer width. --- - { - file: "validation/max-value-size.json", - line: 26, - reason: - 'expected assert_invalid ("exceeds maximum byte size"), but it ' + - "validated — wasmtime does not implement CM#688's elem_size < 2^28 " + - "check yet (third_party/component-model/test/nyi.txt lists this " + - "file; single fixed list just over the limit: `(list u8 " + - "268435456)`); max-value-size-nyi, " + - "https://github.com/polymorph-components/polyengine/issues/248 (pending-capability: upstream nyi.txt)", - }, - { - file: "validation/max-value-size.json", - line: 32, - reason: - 'expected assert_invalid ("exceeds maximum byte size"), but it ' + - "validated — same max-value-size-nyi gap as line 26 (fixed list " + - "whose product exceeds MAX: `(list u64 33554432)`), " + - "https://github.com/polymorph-components/polyengine/issues/248", - }, - { - file: "validation/max-value-size.json", - line: 38, - reason: - 'expected assert_invalid ("exceeds maximum byte size"), but it ' + - "validated — same max-value-size-nyi gap as line 26 (u32-wrap class: " + - "real byte size is 2^32 but a naive u32 multiply wraps to 0: " + - "`(list u64 536870912)`), https://github.com/polymorph-components/polyengine/issues/248", - }, - { - file: "validation/max-value-size.json", - line: 44, - reason: - 'expected assert_invalid ("exceeds maximum byte size"), but it ' + - "validated — same max-value-size-nyi gap as line 26 (compound sum " + - "exceeds MAX via a tuple), https://github.com/polymorph-components/polyengine/issues/248", - }, - { - file: "validation/max-value-size.json", - line: 49, - reason: - 'expected assert_invalid ("exceeds maximum byte size"), but it ' + - "validated — same max-value-size-nyi gap as line 26 (compound sum " + - "exceeds MAX via a record), https://github.com/polymorph-components/polyengine/issues/248", - }, - { - file: "validation/max-value-size.json", - line: 58, - reason: - 'expected assert_invalid ("exceeds maximum byte size"), but it ' + - "validated — same max-value-size-nyi gap as line 26 (nested fixed " + - "list), https://github.com/polymorph-components/polyengine/issues/248", - }, - { + `expected assert_invalid because ${detail}, but the pinned frontend validated it; name-rules NYI, ${issue248}`, + })), + + // The pinned frontend likewise lacks CM#688's elem_size < 2^28 rule; + // test/nyi.txt lists this file. Line 64 is the pointer-width boundary case. + ...[26, 32, 38, 44, 49, 58, 64].map((line) => ({ file: "validation/max-value-size.json", - line: 64, - reason: - 'expected assert_invalid ("exceeds maximum byte size"), but it ' + - "validated — same max-value-size-nyi gap as line 26; this is the " + - "dispatch-flagged pointer-width-sensitive row (`(list string " + - "16777216)`, the i32-vs-i64 elem-size boundary noted in the wast " + - "source comment) — observed identically to the other rows on this " + - "run (wasmparser accepts it outright), https://github.com/polymorph-components/polyengine/issues/248", - }, - // --- values/post-return.json: post-return.wast:4 ($Tester) declares - // every async built-in (task.return, thread.yield/INDEX, waitable-set.*, - // subtask.*, stream.*, future.*) to assert they trap from a post-return - // function. Instantiation requires the unsupported 'thread-index' - // trampoline: deferred-threads, shared-everything threads, - // https://github.com/polymorph-components/polyengine/issues/12. - // The assertions cascade off 'no current instance'. - { - file: "values/post-return.json", - line: 202, - reason: - "UnsupportedFeatureError: component requires host trampoline " + - "'thread-index' — post-return.wast:4 declares the full async built-in " + - "surface incl. 🧵 thread.* built-ins; deferred-threads class, https://github.com/polymorph-components/polyengine/issues/12 " + - "(pending-capability: shared-everything threads)", - }, - { - file: "values/post-return.json", - line: 204, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 206, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 208, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 210, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 212, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 214, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 216, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 218, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 220, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 222, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 224, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 226, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 228, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 230, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 232, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 234, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 236, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 238, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 240, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 242, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 244, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 246, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 248, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 250, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 252, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 254, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - { - file: "values/post-return.json", - line: 256, - reason: "same 🧵 thread-index (deferred threads, https://github.com/polymorph-components/polyengine/issues/12) dependency as line 202", - }, - // Async failures below distinguish unsupported capabilities from assertions - // cascading after a skipped instantiation. Keep each cascade tied to its - // root cause rather than treating it as an independent runtime failure. - // --- async/during-sync-call-*.json + during-sync-scheduling-candidates.json: - // all pin 🧵 sync-call-blocking semantics and are built largely from thread - // built-ins (thread.new-indirect / resume-later / suspend-then-resume / - // suspend / index / yield-then-promote) — the deferred-threads class, - // https://github.com/polymorph-components/polyengine/issues/12. - // Translation and definition succeed, but instantiation requires unsupported - // thread trampolines. `module`/`module_instance` is skipped as - // pending-capability; later assertions cascade with "no current instance". - // These are deferred-threads failures, not the initially suspected - // cm705-sync-sched class (polyengine#249) or the resolved pin-drift class. - { - file: "async/during-sync-call-may-block-if-other-ready-threads.json", - line: 111, - reason: - "cascade of line 110 (module_instance pending-capability: " + - "instantiate requires host trampoline 'thread-new-indirect', " + - "deferred thread built-ins, https://github.com/polymorph-components/polyengine/issues/12): " + - "no current instance", - }, - { - file: "async/during-sync-call-may-block-if-other-ready-threads.json", - line: 112, - reason: "same cascade as line 111, see that entry", - }, - { - file: "async/during-sync-call-may-block-if-other-ready-threads.json", - line: 115, - reason: - "cascade of line 114 (module_instance pending-capability: " + - "instantiate requires host trampoline 'thread-new-indirect', " + - "deferred thread built-ins, https://github.com/polymorph-components/polyengine/issues/12): " + - "no current instance", - }, - // CM#705 (polyengine#173) appended a second component to this file (a new - // "setup"/"run" pair driven by thread.new-indirect/index/resume-later/ - // suspend) — same deferred-threads mechanism as above, at a fresh offset. - // Its own module_instance command (line 136) is pending-capability - // ('thread-new-indirect') and needs no xfail entry (skipped, not failed). - { - file: "async/during-sync-call-may-block-if-other-ready-threads.json", - line: 206, - reason: - "cascade of line 136 (module_instance pending-capability: " + - "instantiate requires host trampoline 'thread-new-indirect', " + - "deferred thread built-ins, https://github.com/polymorph-components/polyengine/issues/12): " + - "no current instance", - }, - { - file: "async/during-sync-call-may-block-if-other-ready-threads.json", - line: 207, - reason: "same cascade as line 206, see that entry", - }, - // async/during-sync-call-exclusive-resume.json: thread.index/suspend/ - // resume-later make the module commands pending-capability. Only the - // cascading assertions need xfail entries (deferred-threads, #12). - { - file: "async/during-sync-call-exclusive-resume.json", - line: 59, - reason: - "cascade of line 9 (module pending-capability: instantiate requires " + - "host trampoline 'thread-index', deferred thread built-ins, " + - "https://github.com/polymorph-components/polyengine/issues/12): no current instance", - }, - { - file: "async/during-sync-call-exclusive-resume.json", - line: 60, - reason: "same cascade as line 59, see that entry", - }, - { - file: "async/during-sync-call-exclusive-resume.json", - line: 102, - reason: - "cascade of line 65 (module pending-capability: instantiate " + - "requires host trampoline 'thread-suspend', deferred thread " + - "built-ins, https://github.com/polymorph-components/polyengine/issues/12): no current instance", - }, - { - file: "async/during-sync-call-exclusive-resume.json", - line: 103, - reason: "same cascade as line 102, see that entry", - }, - // async/during-sync-scheduling-candidates.json: components built from thread - // built-ins (thread.new-indirect/resume-later/suspend/index/ - // yield-then-promote); every component's `module`/`module_instance` - // command is pending-capability (deferred thread built-ins, #12) and - // needs no xfail entry — the cascading asserts below do. - { - file: "async/during-sync-scheduling-candidates.json", - line: 74, - reason: - "cascade of line 19 (module pending-capability: instantiate " + - "requires host trampoline 'thread-new-indirect', deferred thread " + - "built-ins, https://github.com/polymorph-components/polyengine/issues/12): no current instance", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 75, - reason: "same cascade as line 74, see that entry", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 132, - reason: - "cascade of line 78 (module_instance pending-capability: " + - "instantiate requires host trampoline 'thread-index', deferred " + - "thread built-ins, https://github.com/polymorph-components/polyengine/issues/12): no current instance", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 133, - reason: "same cascade as line 132, see that entry", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 134, - reason: "same cascade as line 132, see that entry", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 135, - reason: "same cascade as line 132, see that entry", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 234, - reason: - "cascade of line 144 (module_instance pending-capability: " + - "instantiate requires host trampoline 'thread-index', deferred " + - "thread built-ins, https://github.com/polymorph-components/polyengine/issues/12): no current instance", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 235, - reason: "same cascade as line 234, see that entry", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 236, - reason: "same cascade as line 234, see that entry", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 237, - reason: "same cascade as line 234, see that entry", - }, - // BlockedCallbackTester defines successfully; instantiating it requires - // an unsupported thread trampoline. - { - file: "async/during-sync-scheduling-candidates.json", - line: 304, - reason: - "cascade of line 303 (module_instance pending-capability: " + - "instantiate requires host trampoline 'thread-index', deferred " + - "thread built-ins, https://github.com/polymorph-components/polyengine/issues/12): no current instance", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 305, - reason: "same cascade as line 304, see that entry", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 308, - reason: - "cascade of line 307 (module_instance pending-capability: " + - "instantiate requires host trampoline 'thread-index', deferred " + - "thread built-ins, https://github.com/polymorph-components/polyengine/issues/12): no current instance", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 309, - reason: "same cascade as line 308, see that entry", - }, - // SyncLiftedTester has the same instantiation-time thread dependency. - { - file: "async/during-sync-scheduling-candidates.json", - line: 404, + line, reason: - "cascade of line 403 (module_instance pending-capability: " + - "instantiate requires host trampoline 'thread-index', deferred " + - "thread built-ins, https://github.com/polymorph-components/polyengine/issues/12): no current instance", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 405, - reason: "same cascade as line 404, see that entry", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 408, - reason: - "cascade of line 407 (module_instance pending-capability: " + - "instantiate requires host trampoline 'thread-index', deferred " + - "thread built-ins, https://github.com/polymorph-components/polyengine/issues/12): no current instance", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 409, - reason: "same cascade as line 408, see that entry", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 482, - reason: - "cascade of line 414 (module pending-capability: instantiate " + - "requires host trampoline 'thread-new-indirect', deferred thread " + - "built-ins, https://github.com/polymorph-components/polyengine/issues/12): no current instance", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 483, - reason: "same cascade as line 482, see that entry", - }, - { - file: "async/during-sync-scheduling-candidates.json", - line: 484, - reason: "same cascade as line 482, see that entry", - }, - // async/during-sync-call-no-sibling-resume.json: same deferred-threads - // mechanism; its `module` commands (lines 16, 162) are pending-capability - // ('thread-new-indirect', 'thread-suspend') and need no xfail entry. - { - file: "async/during-sync-call-no-sibling-resume.json", - line: 155, - reason: - "cascade of line 16 (module pending-capability: instantiate " + - "requires host trampoline 'thread-new-indirect', deferred thread " + - "built-ins, https://github.com/polymorph-components/polyengine/issues/12): no current instance", - }, + `expected assert_invalid (exceeds maximum byte size), but the pinned frontend validated it; max-value-size NYI, ${issue248}`, + })), + + // These are current runtime diagnostic/scheduling gaps, not deferred-thread + // cascades. The thread built-ins instantiate and execute; each row below was + // re-run directly after that support landed. { file: "async/during-sync-call-no-sibling-resume.json", line: 156, - reason: "same cascade as line 155, see that entry", - }, - { - file: "async/during-sync-call-no-sibling-resume.json", - line: 214, reason: - "cascade of line 162 (module pending-capability: instantiate " + - "requires host trampoline 'thread-suspend', deferred thread " + - "built-ins, https://github.com/polymorph-components/polyengine/issues/12): no current instance", - }, - { - file: "async/during-sync-call-no-sibling-resume.json", - line: 215, - reason: "same cascade as line 214, see that entry", + `guest reaches unreachable instead of returning during the no-sibling-resume schedule; sync scheduling gap, ${issue249}`, }, - // --- async/futures-must-write.json: root cause: STREAMS --- - // --- async/reentrance.json: the remaining entries are the deferred - // thread-built-in cascade (#12), NOT reentrance. --- - { - file: "async/reentrance.json", - line: 522, - reason: - "cascade: this component's `module` command is pending-capability " + - "(instantiate requires a host trampoline for a deferred thread " + - "built-in — waitable-set.new/waitable.join/subtask.cancel plus " + - "thread built-ins, https://github.com/polymorph-components/polyengine/issues/12); NOT reentrance-related", - }, - { - file: "async/reentrance.json", - line: 645, - reason: - "cascade of line 522 (module pending-capability, deferred thread " + - "built-ins, https://github.com/polymorph-components/polyengine/issues/12): no current instance (not reentrance-related)", - }, - { - file: "async/reentrance.json", - line: 760, - reason: - "cascade of line 657 (module pending-capability, deferred thread " + - "built-ins, https://github.com/polymorph-components/polyengine/issues/12): no current instance (not reentrance-related)", - }, - // --- async/self-switch-traps.json: NEW file added by the CM#687 - // thread.*-then-promote built-ins (third_party/component-model advance - // 2f13265 -> 7c67611, this dispatch). Its Tester component needs a host - // trampoline for `thread-index`, not implemented by this executor yet - // (deferred thread built-ins, https://github.com/polymorph-components/polyengine/issues/12); every - // module_instance command against it is pending-capability/SKIPPED (no - // xfail entry needed) and every assert cascades with "no current - // instance". Also listed in upstream's own - // third_party/component-model/test/nyi.txt at this pin. --- - { + ...[46, 48, 50, 52].map((line) => ({ file: "async/self-switch-traps.json", - line: 46, + line, reason: - "cascade of line 45 (module_instance pending-capability: " + - "instantiate requires host trampoline 'thread-index', deferred " + - "thread built-ins, https://github.com/polymorph-components/polyengine/issues/12): no current instance", - }, - { - file: "async/self-switch-traps.json", - line: 48, - reason: "same cascade as line 46, see that entry", - }, - { - file: "async/self-switch-traps.json", - line: 50, - reason: "same cascade as line 46, see that entry", - }, - { - file: "async/self-switch-traps.json", - line: 52, - reason: "same cascade as line 46, see that entry", - }, - // --- async/switch-to-ready-callback.json: NEW file, same CM#687 advance - // as self-switch-traps.json above. Same root cause: 'thread-index' - // deferred (https://github.com/polymorph-components/polyengine/issues/12); every module_instance command against - // Tester is pending-capability/SKIPPED (no xfail entry needed) and every - // assert cascades with "no current instance". --- - { + `operation traps as required, but reports "cannot resume the current thread" instead of "cannot resume thread which is not suspended"; diagnostic mismatch, ${issue249}`, + })), + ...[355, 357, 363, 365].map((line) => ({ file: "async/switch-to-ready-callback.json", - line: 355, + line, reason: - "cascade of line 354 (module_instance pending-capability: " + - "instantiate requires host trampoline 'thread-index', deferred " + - "thread built-ins, https://github.com/polymorph-components/polyengine/issues/12): no current instance", - }, - { - file: "async/switch-to-ready-callback.json", - line: 357, - reason: "same cascade as line 355, see that entry", - }, - { - file: "async/switch-to-ready-callback.json", - line: 359, - reason: "same cascade as line 355, see that entry", - }, - { - file: "async/switch-to-ready-callback.json", - line: 361, - reason: "same cascade as line 355, see that entry", - }, - { - file: "async/switch-to-ready-callback.json", - line: 363, - reason: "same cascade as line 355, see that entry", - }, - { - file: "async/switch-to-ready-callback.json", - line: 365, - reason: "same cascade as line 355, see that entry", - }, - { - file: "async/switch-to-ready-callback.json", - line: 367, - reason: "same cascade as line 355, see that entry", - }, - { - file: "async/switch-to-ready-callback.json", - line: 369, - reason: "same cascade as line 355, see that entry", - }, - // --- async/trap-if-block-and-sync.json: (history: at the prior pin the - // whole file was blocked by the now-exited wasmparser/wast pin-drift - // class — see the EXIT note at the top of this file, $Tester's canonical - // section used 🧵 thread built-in encodings that the old decoder - // misparsed.) $Tester TRANSLATES AND DEFINES fine at the current pin - // (line 5 is stale here and pruned, confirmed by the stale-xfail - // detector); the surviving gap is one level down — instantiating it - // needs a host trampoline for a deferred thread built-in - // (`thread-yield-then-resume`), which this executor does not implement - // yet (deferred threads, https://github.com/polymorph-components/polyengine/issues/12). - // Every `(component instance $i $Tester)` command in the file is - // therefore pending-capability and SKIPPED (not failed; no xfail entry - // needed), and every assert against it cascades with "no current - // instance". CM#705 grew the file from 17 to 18 exported tests - // (trap-if-sync-cancel plus the four sync-stream/-future rows); the - // subsequent re-pin further changed the exact command layout (module_instance - // + assert pairs interleave 1:1 now, at lines 315-360, rather than the - // old single-definition-then-39-cascades shape at lines 5/273-311), so - // the old cascade line numbers (273-311) no longer correspond to any - // command in the regenerated corpus and have been replaced with the - // current ones below. --- + `operation traps as required, but reports "cannot resume thread" instead of "cannot resume thread which is not suspended"; diagnostic mismatch, ${issue249}`, + })), { file: "async/trap-if-block-and-sync.json", line: 316, reason: - "cascade of line 315 (module_instance pending-capability: instantiate " + - "requires host trampoline 'thread-yield-then-resume', deferred " + - "thread built-ins, https://github.com/polymorph-components/polyengine/issues/12): no current instance", + `reports the host driver's deadlock diagnostic instead of the synchronous-callee cannot-block diagnostic; sync scheduling gap, ${issue249}`, }, - { + ...[328, 330, 332].map((line) => ({ file: "async/trap-if-block-and-sync.json", - line: 318, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 320, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 322, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 324, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 326, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 328, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 330, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 332, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 334, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 336, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 338, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 340, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 342, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 344, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 346, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 348, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 350, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 352, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 354, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 356, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 358, - reason: "same cascade as line 316, see that entry", - }, - { - file: "async/trap-if-block-and-sync.json", - line: 360, - reason: "same cascade as line 316, see that entry", - }, - // --- async/trap-if-done.json: root cause: STREAMS --- - // --- async/trap-if-sync-and-waitable-set.json: root cause: deferred - // thread built-ins (https://github.com/polymorph-components/polyengine/issues/12) — this file's Tester component - // needs a host trampoline for `thread-new-indirect`, which this executor - // does not implement yet, so every `module_instance` command against it - // is pending-capability/SKIPPED (no xfail entry needed) and every assert - // cascades with "no current instance". Also listed in upstream's own - // third_party/component-model/test/nyi.txt at this pin. --- - { - file: "async/trap-if-sync-and-waitable-set.json", - line: 301, - reason: - "cascade of this file's first failure: the component was " + - "declined at instantiation, so no instance exists for this " + - "command", - }, - { - file: "async/trap-if-sync-and-waitable-set.json", - line: 303, + line, reason: - "cascade of this file's first failure: the component was " + - "declined at instantiation, so no instance exists for this " + - "command", - }, - { - file: "async/trap-if-sync-and-waitable-set.json", - line: 305, + `reports a specific invalid callback code instead of the corpus's unsupported-callback-code diagnostic; diagnostic mismatch, ${issue249}`, + })), + ...[204, 206].map((line) => ({ + file: "values/post-return.json", + line, reason: - "cascade of this file's first failure: the component was " + - "declined at instantiation, so no instance exists for this " + - "command", - }, - { - file: "async/trap-if-sync-and-waitable-set.json", - line: 307, + `post-return operation traps as required, but reports "may_leave violation" instead of "cannot leave component instance"; diagnostic mismatch, ${issue249}`, + })), + ...[212, 218, 226, 232, 234, 236, 238, 246, 248, 250, 252].map((line) => ({ + file: "values/post-return.json", + line, reason: - "cascade of this file's Tester module_instance command " + - "(pending-capability: instantiate requires host trampoline " + - "'thread-new-indirect', deferred thread built-ins, https://github.com/polymorph-components/polyengine/issues/12): " + - "no current instance — new row, file grew (CM#715)", - }, - { - file: "async/trap-if-sync-and-waitable-set.json", - line: 309, - reason: "same cascade as line 307, see that entry", - }, - { - file: "async/trap-if-sync-and-waitable-set.json", - line: 311, - reason: "same cascade as line 307, see that entry", - }, - { - file: "async/trap-if-sync-and-waitable-set.json", - line: 313, - reason: "same cascade as line 307, see that entry", - }, - { - file: "async/trap-if-sync-and-waitable-set.json", - line: 315, - reason: "same cascade as line 307, see that entry", - }, - { - file: "async/trap-if-sync-and-waitable-set.json", - line: 317, - reason: "same cascade as line 307, see that entry", - }, - { - file: "async/trap-if-sync-and-waitable-set.json", - line: 319, - reason: "same cascade as line 307, see that entry", - }, - { - file: "async/trap-if-sync-and-waitable-set.json", - line: 321, - reason: "same cascade as line 307, see that entry", - }, - { - file: "async/trap-if-sync-and-waitable-set.json", - line: 323, - reason: "same cascade as line 307, see that entry", - }, - { - file: "async/trap-if-sync-and-waitable-set.json", - line: 325, - reason: "same cascade as line 307, see that entry", - }, - { - file: "async/trap-if-sync-and-waitable-set.json", - line: 327, - reason: "same cascade as line 307, see that entry", - }, - // --- async/trap-if-transfer-in-waitable-set.json: root cause: STREAMS --- - // --- async/wait-during-callback.json: root cause: STREAMS --- - // --- async/zero-length.json: GREEN under jspi auto-detection (the jspi flip); - // entry pruned. --- + `post-return blocking builtin escapes as a JSPI SuspendError instead of the required Component Model trap; post-return boundary gap, ${issue249}`, + })), ]; export function isXfail(file: string, line: number): boolean { return XFAIL.some((e) => e.file === file && e.line === line); } -// --------------------------------------------------------------------------- -// Schedule-profile-dependent corpus files — a different axis from XFAIL -// above. -// -// XFAIL entries mean "the engine can't do this yet" (a capability gap). -// The entries below mean the opposite: the ENGINE is fine, but the guest -// component itself encodes an assumption that only holds under the -// reference interpreter's DETERMINISTIC_PROFILE -// (third_party/component-model/design/mvp/canonical-abi/definitions.py:1373), -// where `Store.tick` (definitions.py:603) resolves ties in a fixed order -// instead of `random.choice`. Our default FIFO policy matches that profile, -// so these files are fully green normally — they only need to be skipped -// when `POLYENGINE_SCHED_SEED` deliberately explores schedules BEYOND it -// (see runtime/src/task/scheduler.ts's `readSeed`/seeded-shuffle policy). -// -// async/async-calls-sync.json (async-calls-sync.wast:183 area) is the -// precedent for this class: the guest asserts each subtask's RETURNED value -// equals its subtask index, where that index is `$AsyncInner`'s `$counter`, -// handed out in the order backpressured tasks are RELEASED. That release -// order is pinned only under DETERMINISTIC_PROFILE; under a seed the guest -// can legitimately observe a different release order and hit its own -// `unreachable` — a profile-dependent guest assumption failing, not an -// engine fault. This is the exact class runtime/tests/jspi/handshake_test.ts -// (lines ~40-58) already self-skips for the same file, for the same reason, -// on the runtime side; this set lets harness/tests/conformance_test.ts do -// the analogous self-skip on the conformance-suite side. -// -// Measured (orchestrator, this repo): reproduces identically at -// POLYENGINE_SCHED_SEED = 1, 2, 3, 7, 4242, 99991 — always exactly this one -// corpus file failing (wast lines 250/251, "expected return, got trap: -// guest trapped: unreachable"), consistent with a scheduling-order-dependent -// guest assertion rather than a flake or an engine regression. -// -// Corpus-relative path, same shape as XfailEntry.file. +// Schedule-profile-dependent corpus files are a different axis from XFAIL. +// This guest asserts an order guaranteed only by definitions.py's +// DETERMINISTIC_PROFILE. Seeded runs deliberately explore other valid orders. export const DETERMINISTIC_PROFILE_ONLY: ReadonlySet = new Set([ "async/async-calls-sync.json", ]); diff --git a/harness/tests/runner_unit_test.ts b/harness/tests/runner_unit_test.ts index 6183eadc..1c364b74 100644 --- a/harness/tests/runner_unit_test.ts +++ b/harness/tests/runner_unit_test.ts @@ -264,21 +264,28 @@ Deno.test("trapMatches: an unrelated engine trap message does not falsely match }); Deno.test("trapMatches: Bun's exact core `unreachable` diagnostic matches every corpus expectation", () => { - const actual = - "guest trapped: Unreachable code should not be executed (evaluating 'fn(...args)')"; - for ( - const expected of [ - "wasm trap: wasm `unreachable` instruction executed", - "unreachable", - "wasm `unreachable` instruction executed", - ] - ) { - assertEq(trapMatches(expected, actual), true, expected); - assertEq( - trapMatches(expected, `${actual} additional suffix`), - false, - `${expected} rejects an unverified suffix`, - ); + for (const call of ["fn()", "fn(...args)"]) { + const actual = + `guest trapped: Unreachable code should not be executed (evaluating '${call}')`; + for ( + const expected of [ + "wasm trap: wasm `unreachable` instruction executed", + "unreachable", + "wasm `unreachable` instruction executed", + ] + ) { + assertEq(trapMatches(expected, actual), true, `${expected}: ${call}`); + assertEq( + trapMatches(expected, `${actual} additional suffix`), + false, + `${expected}: ${call} rejects an unverified suffix`, + ); + assertEq( + trapMatches(expected, `prefix ${actual}`), + false, + `${expected}: ${call} rejects an unverified prefix`, + ); + } } }); @@ -346,6 +353,18 @@ Deno.test("trapMatches: verified diagnostic equivalents match only their named o "cannot write after being notified that the readable end dropped", "cannot write to stream after being notified that the readable end dropped", ], + [ + "async-lifted export failed to produce a result", + "task finished all threads without resolving", + ], + [ + "invalid `task.return` signature and/or options for current task", + "task.return with a result type that is not the task's result type", + ], + [ + "invalid `task.return` signature and/or options for current task", + "task.return with canonical options differing from the task's", + ], [ "cannot read from and write to intra-component future/stream with non-numeric payload", "cannot read from and write to intra-component future", @@ -372,6 +391,14 @@ Deno.test("trapMatches: narrow diagnostic rows reject adjacent but different tra "cannot write after being notified that the readable end dropped", "cannot write to future after previous write succeeded or readable end dropped", ], + [ + "async-lifted export failed to produce a result", + "task finished without resolving (deadlock)", + ], + [ + "invalid `task.return` signature and/or options for current task", + "task.return on a resolved task", + ], [ "cannot read from and write to intra-component future/stream with non-numeric payload", "cannot have concurrent operations active on a future/stream", @@ -398,20 +425,36 @@ Deno.test("trapMatches: an equivalent poison cause does not match a later entry }); Deno.test("trapMatches: exact equivalents reject expected and actual affixes", () => { - const expected = "backpressure counter overflow"; - const actual = "backpressure counter underflow"; for ( - const [changedExpected, changedActual] of [ - [`prefix ${expected}`, actual], - [`${expected} suffix`, actual], - [expected, `prefix ${actual}`], - [expected, `${actual} suffix`], + const [expected, actual] of [ + ["backpressure counter overflow", "backpressure counter underflow"], + [ + "async-lifted export failed to produce a result", + "task finished all threads without resolving", + ], + [ + "invalid `task.return` signature and/or options for current task", + "task.return with a result type that is not the task's result type", + ], + [ + "invalid `task.return` signature and/or options for current task", + "task.return with canonical options differing from the task's", + ], ] ) { - assertEq( - trapMatches(changedExpected, changedActual), - false, - `${changedExpected} / ${changedActual}`, - ); + for ( + const [changedExpected, changedActual] of [ + [`prefix ${expected}`, actual], + [`${expected} suffix`, actual], + [expected, `prefix ${actual}`], + [expected, `${actual} suffix`], + ] + ) { + assertEq( + trapMatches(changedExpected, changedActual), + false, + `${changedExpected} / ${changedActual}`, + ); + } } }); diff --git a/harness/tests/wasmtime_expectations_test.ts b/harness/tests/wasmtime_expectations_test.ts index 115e8ac1..f02a3457 100644 --- a/harness/tests/wasmtime_expectations_test.ts +++ b/harness/tests/wasmtime_expectations_test.ts @@ -5,6 +5,9 @@ import { WASMTIME_SKIP_EXPECTATIONS, } from "../src/wasmtime-expectations.ts"; import { classify } from "../src/wasmtime-classifier.ts"; +import type { WastJson } from "../src/schema.ts"; +import { runWastJson } from "../src/runner.ts"; +import { RuntimeExecutor } from "../src/runtime-executor.ts"; function assert(condition: boolean, message: string): void { if (!condition) throw new Error(message); @@ -74,6 +77,10 @@ Deno.test("classifier rejects stale passes and unexpected skips", () => { Deno.test("skip expectations require exact line and full cause", () => { const entry = WASMTIME_SKIP_EXPECTATIONS[0]; + if (entry === undefined) { + assert(WASMTIME_SKIP_EXPECTATIONS.length === 0, "unexpected skip entry"); + return; + } const base = { type: "module", status: "skipped" as const, @@ -143,3 +150,70 @@ Deno.test("spectest resource probe preserves rep and destructor counters", async "resource counters lost", ); }); + +Deno.test("spectest exposes gc only for the deferred-frame boundary fixture", async () => { + const { wasmtimeSpectest } = await import("../src/wasmtime-spectest.ts"); + const ordinary = wasmtimeSpectest("async/futures.json"); + assert( + !("wasmtime" in ordinary.imports), + "wasmtime provider leaked into an unrelated fixture", + ); + + const probe = wasmtimeSpectest("async/context-in-resource-drop.json"); + const wasmtime = probe.imports.wasmtime as Record< + string, + (...args: unknown[]) => unknown + >; + assert(typeof wasmtime.gc === "function", "gc provider is absent"); + assert( + probe.counters.forcedHostBoundaries === 0, + "counter did not start at zero", + ); + wasmtime.gc(); + assert( + probe.counters.forcedHostBoundaries === 1, + "gc provider invocation was not observed", + ); + assert( + !("set-max-table-capacity" in wasmtime), + "native table-capacity control must not be emulated", + ); +}); + +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, + ); + const doc = JSON.parse( + await Deno.readTextFile( + new URL("context-in-resource-drop.json", generated), + ), + ) as WastJson; + const probe = wasmtimeSpectest("async/context-in-resource-drop.json"); + const executor = await RuntimeExecutor.create( + await Deno.readFile( + new URL( + "target/wasm32-unknown-unknown/release/translator_shim.wasm", + root, + ), + ), + probe.imports, + ); + const result = await runWastJson( + doc, + (name) => Deno.readFile(new URL(name, generated)), + executor, + ); + + assert( + result.results.every((row) => row.status === "passed"), + `fixture did not pass: ${JSON.stringify(result.results)}`, + ); + assert( + probe.counters.forcedHostBoundaries === 4, + `expected four destructor host-boundary calls, got ${probe.counters.forcedHostBoundaries}`, + ); +}); diff --git a/justfile b/justfile index 80cf5b29..d30b82c7 100644 --- a/justfile +++ b/justfile @@ -195,6 +195,7 @@ shell-lane lane *args: shim corpus # Bun is findings-only (required: false); infrastructure failures still gate. # Pinned shell lanes: SpiderMonkey/Node on both Linux arches, JSC on x64 only, plus Bun. shells: + deno test --allow-read=. --allow-run tools/shell/run-lane_test.ts just shell-lane sm-pinned @if [ "$(uname -m)" = "x86_64" ]; then just shell-lane jsc-pinned; else echo "jsc-pinned: skipped (no arm64 channel)"; fi just shell-lane node-pinned diff --git a/runtime/src/cabi/trap.ts b/runtime/src/cabi/trap.ts index 1c57f61b..effd28e4 100644 --- a/runtime/src/cabi/trap.ts +++ b/runtime/src/cabi/trap.ts @@ -7,8 +7,8 @@ // `Trap` represents a Component Model trap. `AssertionError` represents // reference assertions and host-precondition violations, such as an invalid // value supplied to scalar lowering, not a guest's canonical trap outcome. -// Throwing a JS exception does not itself ensure guest uncatchability; -// see intrinsics/mod.ts `HostTrapState` for that limitation. +// Guest-facing trampoline failures use task/scheduler.ts's native core trap +// carrier because throwing this JS value directly is catchable by Wasm EH. import { Trap } from "@polyengine/protocol"; diff --git a/runtime/src/exec/boundary.ts b/runtime/src/exec/boundary.ts index bf5ad7dc..308f966f 100644 --- a/runtime/src/exec/boundary.ts +++ b/runtime/src/exec/boundary.ts @@ -33,6 +33,7 @@ import { assert_, AssertionError, Trap } from "../cabi/trap.ts"; import { type BlockRequest, type Cancelled, + componentBoundaryTrapCarrier, type ComponentInstanceState, consumeSchedulerFailure, driveSyncLift, @@ -53,6 +54,7 @@ import { Subtask, SubtaskState, SyncEntryBusy, + takeComponentBoundaryTrap, Task, type TaskOptions, Thread, @@ -261,12 +263,24 @@ export function callCore(fn: CoreFn, args: CoreValue[]): CoreValue[] { */ function mapCoreException(e: unknown): unknown { if (e instanceof WebAssembly.RuntimeError) { + const carried = takeComponentBoundaryTrap(e); + if (carried !== undefined) return mapCoreException(carried.cause); try { trap(`guest trapped: ${e.message}`); } catch (t) { return t; } } + const Exception = (WebAssembly as unknown as { + Exception?: abstract new (...args: never[]) => object; + }).Exception; + if (Exception !== undefined && e instanceof Exception) { + try { + trap("thrown Wasm exception"); + } catch (t) { + return t; + } + } return e; } @@ -512,6 +526,15 @@ type DrainState = { waiters: Set; budget: number; hopProbe: { hops: Set; elapsed: boolean } | null; + syncHopProbe: { hops: Set; elapsed: boolean } | null; + admissions: Set; +}; + +type Admission = { + inst: unknown; + run(): unknown; + resolve(value: unknown): void; + reject(cause: unknown): void; }; const drainStates = new WeakMap(); @@ -529,6 +552,8 @@ function stateFor(store: Store): DrainState { waiters: new Set(), budget: WORK_QUANTUM, hopProbe: null, + syncHopProbe: null, + admissions: new Set(), }; drainStates.set(store, state); store.serviceRequested = () => requestStoreService(store); @@ -577,6 +602,73 @@ function serviceAdmissionTailStep(store: Store): boolean { return store.serviceSettledStep(); } +/** Offer deferred host entries between complete canonical steps. This runs + * only under the coordinator's no-live-guest guard, never from mutation sites. */ +function serviceAdmissionStep(store: Store, state: DrainState): boolean { + for (const admission of state.admissions) { + if (entryHopThreads(store, admission.inst).length > 0) continue; + state.admissions.delete(admission); + try { + admission.resolve(admission.run()); + } catch (e) { + admission.reject(e); + } + return true; + } + return false; +} + +type RequiredSyncVerdict = "none" | "progress" | "wait"; + +/** Apply definitions.py's synchronous `canon_lift` loop to a logical FACT + * child that reached a real CM park. This precedes store-global idle/retention + * policy: only the callee instance's work can justify the synchronous wait. */ +function serviceRequiredSyncStep( + store: Store, + state: DrainState, +): RequiredSyncVerdict { + while (store.requiredSyncParks.length > 0) { + const point = store.requiredSyncParks[0]; + if (!point.waiting()) { + store.finishSyncProgress(point); + continue; + } + const owner = point.owner; + const task = point.logicalOwner.task; + const root = task?.failureOwner; + if ( + root?.inst?.activeCalls instanceof Set && + !root.inst.activeCalls.has(root) + ) { + // The host-visible result was published before this background park. + store.finishSyncProgress(point); + continue; + } + const inst = task?.inst; + if (store.serviceSettledStepFor(inst)) { + state.syncHopProbe = null; + return "progress"; + } + const hops = entryHopThreads(store, inst); + if (hops.length > 0 || store.pendingResumptions.has(owner)) return "wait"; + if (store.tickForInstance(inst)) { + state.syncHopProbe = null; + return "progress"; + } + store.finishSyncProgress(point); + point.abandon( + componentBoundaryTrapCarrier( + new Trap("wasm trap: cannot block a synchronous task before returning"), + owner, + ), + ); + state.syncHopProbe = null; + return "progress"; + } + state.syncHopProbe = null; + return "none"; +} + function chargeWorkQuantum(state: DrainState): boolean { return --state.budget <= 0; } @@ -737,6 +829,51 @@ async function runStoreDrain(store: Store, state: DrainState): Promise { } else return; } try { + store.refreshSyncRequirements(); + if (serviceAdmissionStep(store, state)) continue; + const syncVerdict = serviceRequiredSyncStep(store, state); + if (syncVerdict === "progress") continue; + if (syncVerdict === "wait") { + const point = store.requiredSyncParks[0]; + const hops = point === undefined + ? [] + : entryHopThreads(store, point.logicalOwner.task?.inst); + const blockers = point !== undefined && + store.pendingResumptions.has(point.owner) + ? [...hops, point.owner] + : hops; + if ( + state.syncHopProbe === null || + !sameIdentities(state.syncHopProbe.hops, blockers) + ) { + const probe = { hops: new Set(blockers), elapsed: false }; + state.syncHopProbe = probe; + setTimeout(() => { + if (state.syncHopProbe === probe) { + probe.elapsed = true; + requestStoreService(store); + } + }, 0); + return; + } + if (!state.syncHopProbe.elapsed) return; + // An engine-only hop gets one platform turn to become a real park or + // settlement. It is not itself permission for a sync callee to wait. + state.syncHopProbe = null; + if (blockers.length > 0 && point !== undefined) { + store.finishSyncProgress(point); + point.abandon( + componentBoundaryTrapCarrier( + new Trap( + "wasm trap: cannot block a synchronous task before returning", + ), + point.owner, + ), + ); + continue; + } + return; + } const unsettledHops = unsettledEntryHops(store); if (unsettledHops.length > 0) { // Host retention and real host calls both make this a valid wait. @@ -772,6 +909,11 @@ async function runStoreDrain(store: Store, state: DrainState): Promise { state.hopProbe = null; while (!store.hasPendingResumptions()) { if (!serviceOrdinaryStep(store)) break; + // A settled tail or one scheduler tick is one complete canonical + // step. Admission gets a turn before another ordinary tick, so a + // perpetual callback YIELD loop cannot starve a pending sync export. + store.refreshSyncRequirements(); + if (serviceAdmissionStep(store, state)) break; if (chargeWorkQuantum(state)) { await handoffWorkQuantum(store, state); } @@ -887,8 +1029,6 @@ export function createLiftedFunction(input: { * Promise. */ suspensionMode?: SuspensionMode; - /** Optional; see intrinsics `HostTrapState`. */ - trapState?: { pending: unknown }; /** * Optional; the executor's sync-call scope stack (intrinsics * `SyncCallScope`). Structural, to keep this module free of an import @@ -909,6 +1049,9 @@ export function createLiftedFunction(input: { /** Nested guest destructor only: preserve the caller and use the reference * sync lift drive, not the host's store-wide completion policy. */ guestDtorCaller?: ComponentInstanceState | null; + /** Guest destructor exceptions must reach FACT's exception barrier, which + * assigns the canonical UncaughtException trap category. */ + preserveWasmException?: boolean; /** * Refuse instance entry hops rather than deferring. SYNC_ENTRY uses plain * mode inside a JSPI instantiation, so its own mode cannot identify this @@ -927,7 +1070,6 @@ export function createLiftedFunction(input: { opts, core, stats, - trapState, syncCallStack, allInstances, } = input; @@ -971,9 +1113,6 @@ export function createLiftedFunction(input: { hostInput: ComponentValue[] | PreparedTransfer, ): unknown => { stats.liftedCalls++; - // A trap remembered during an earlier call must never be attributed to - // this one (see intrinsics `HostTrapState`). - if (!guestDtor && trapState !== undefined) trapState.pending = undefined; // Depth of the sync-call scope stack on entry; see the `finally` below. const syncCallDepth = syncCallStack?.length ?? 0; @@ -1119,6 +1258,7 @@ export function createLiftedFunction(input: { mode, prepared, admissionCheckpoint, + preserveWasmException: input.preserveWasmException, }), ); @@ -1377,13 +1517,24 @@ export function createLiftedFunction(input: { // Genuine SuspensionPoint parks are excluded, allowing host-import reentry // (`runtime/tests/jspi/hop_atomicity_test.ts`). This is not a general entry lock. if (mode === "jspi" && entryHopThreads(store, inst).length > 0) { - return awaitHopQuiescence(store, inst).then( - () => invokeNow(hostArgs), - (e) => { + let resolve!: (value: unknown) => void; + let reject!: (cause: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + const admission: Admission = { + inst, + run: () => invokeNow(hostArgs), + resolve, + reject: (e) => { prepared?.cleanup(e); - throw e; + reject(e); }, - ); + }; + stateFor(store).admissions.add(admission); + requestStoreService(store); + return promise; } return invokeNow(hostArgs); }; @@ -1416,23 +1567,6 @@ function entryHopThreads( return out; } -/** - * Await entry hops and service their tails before rechecking. Hops settle on - * the engine's schedule; genuinely blocked activations are excluded. Multiple - * gated callers recheck independently, with no FIFO admission guarantee. - */ -async function awaitHopQuiescence(store: Store, inst: unknown): Promise { - requestStoreService(store); - await DRAIN_TICK; - for (;;) { - const hops = entryHopThreads(store, inst); - if (hops.length === 0) return; - const revision = store.serviceProgressRevision(); - await store.waitForServiceProgress(revision); - await DRAIN_TICK; - } -} - // --------------------------------------------------------------------------- // Resource destructor entries // --------------------------------------------------------------------------- @@ -1486,7 +1620,6 @@ export function createDtorEntry(input: { instance: ComponentInstanceState; suspensionMode?: SuspensionMode; stats?: ExecutionStats; - trapState?: { pending: unknown }; syncCallStack?: LenderScope[]; allInstances?: () => Iterable<{ mayLeave: boolean }>; /** Present only for guest drops; null is a guest call without a real caller. */ @@ -1513,15 +1646,27 @@ export function createDtorEntry(input: { core, stats: input.stats ?? newStats(), suspensionMode: mode, - trapState: input.trapState, syncCallStack: input.syncCallStack, allInstances: input.allInstances, // The host does not wait for a destructor: `drop(): void` is // non-blocking, and an unfinished dtor's tail is driven by the store. allowAsyncCompletion: !guest, guestDtorCaller: input.guestCaller, + preserveWasmException: guest, }); - return (rep: number) => lifted(rep); + return (rep: number) => { + try { + return lifted(rep); + } catch (e) { + const Exception = (WebAssembly as unknown as { + Exception?: abstract new (...args: never[]) => object; + }).Exception; + if (guest && Exception !== undefined && e instanceof Exception) { + trap("wasm trap: uncaught exception propagated out of component"); + } + throw e; + } + }; } /** @@ -1567,9 +1712,25 @@ export function* awaitCore( args: CoreValue[], // deno-lint-ignore no-explicit-any thread: any, + mapWasmException = true, ): Generator { // The bridge explicitly maintains ambient claims after this bracket unwinds. - const raw = withActivation(thread, () => callCore(fn, args)); + let raw: CoreValue[]; + try { + raw = withActivation(thread, () => fn(...args)) as CoreValue[]; + if (raw === undefined) raw = []; + else if (!Array.isArray(raw)) raw = [raw as unknown as CoreValue]; + } catch (e) { + const Exception = (WebAssembly as unknown as { + Exception?: abstract new (...args: never[]) => object; + }).Exception; + if ( + !mapWasmException && Exception !== undefined && e instanceof Exception + ) { + throw e; + } + throw mapCoreException(e); + } // `callCore` normalizes a bare value to a one-element array; a promising // entry yields `[Promise]`. if (raw.length === 1 && isPromiseLike(raw[0])) { @@ -1580,6 +1741,13 @@ export function* awaitCore( awaitValue: Promise.resolve(raw[0] as unknown as Promise).then( undefined, (e) => { + const Exception = (WebAssembly as unknown as { + Exception?: abstract new (...args: never[]) => object; + }).Exception; + if ( + !mapWasmException && Exception !== undefined && + e instanceof Exception + ) throw e; throw mapCoreException(e); }, ), @@ -1629,6 +1797,7 @@ function* liftBody(input: { mode: SuspensionMode; prepared: PreparedTransfer | null; admissionCheckpoint: () => void; + preserveWasmException?: boolean; }): Generator { const { name, ft, opts, core, stats, task } = input; const thread = input.thread(); @@ -1651,7 +1820,7 @@ function* liftBody(input: { if (!opts.async) { const flatResults = normalizeCoreValues( - yield* awaitCore(core, flatArgs, thread), + yield* awaitCore(core, flatArgs, thread, !input.preserveWasmException), opts.coreType.results, `${name} results`, ); @@ -1685,7 +1854,7 @@ function* liftBody(input: { `without a callback)`, ); } - yield* awaitCore(core, flatArgs, thread); + yield* awaitCore(core, flatArgs, thread, !input.preserveWasmException); task.exitImplicitThread(thread); return; } @@ -1697,7 +1866,7 @@ function* liftBody(input: { input.mode, ); const [packed] = normalizeCoreValues( - yield* awaitCore(core, flatArgs, thread), + yield* awaitCore(core, flatArgs, thread, !input.preserveWasmException), opts.coreType.results, `${name} results`, ) as [number]; @@ -1960,6 +2129,7 @@ export function createLoweredImport(input: { task: currentTask(), readyFunc: () => outcome !== undefined, cancellable: false, + blockReason: "host-import", produce: () => { // Poisoning records a marker; it need not abandon this suspension. // A FACT callee may differ from the still-healthy owning task. diff --git a/runtime/src/exec/executor.ts b/runtime/src/exec/executor.ts index 87856ce5..03f6b0c6 100644 --- a/runtime/src/exec/executor.ts +++ b/runtime/src/exec/executor.ts @@ -11,11 +11,16 @@ import type { FuncType, ValType } from "../cabi/types.ts"; import { Trap } from "../cabi/trap.ts"; -import { ComponentInstanceState, Store } from "../task/mod.ts"; +import { + ComponentInstanceState, + Store, + takeComponentBoundaryTrap, +} from "../task/mod.ts"; import { anySuspendingImport, assertModeConsistent, chooseMode, + enterWasm, isAbortable, isDeferCancel, isSuspending, @@ -52,7 +57,6 @@ import { createTrampoline, createUnsafeIntrinsic, type FactStartScope, - type HostTrapState, type PreparedCall, type SyncCallScope, TranscodeMemory, @@ -361,8 +365,8 @@ class Executor { /** LoweredIndex-es whose host functions carry the `suspending()` brand — * populated by `buildLoweredImport`, read by `importValue`. */ private readonly suspendableLowerings = new Set(); - /** Host trap held across a FACT exception barrier (see `HostTrapState`). */ - readonly trapState: HostTrapState = { pending: undefined }; + /** Cause attribution only for core start functions, which have no Task. */ + private readonly trapScope = {}; /** Export path -> why it has no runtime surface (see `buildExport`). */ readonly omittedExports = new Map(); @@ -653,8 +657,12 @@ class Executor { this.#declaringInstance = null; let instance: WebAssembly.Instance; try { + // Start functions have no Task. Trampolines use this executor's + // trapScope only for cause attribution; currentTask() remains absent. instance = await WebAssembly.instantiate(module, importObject); } catch (e) { + const boundaryCause = takeComponentBoundaryTrap(e); + if (boundaryCause !== undefined) throw boundaryCause.cause; // A SuspendError out of instantiation is a START FUNCTION trying // to suspend: instantiation is never a `promising` activation, so // ANY suspension-capable call from a start function trips jspi @@ -775,7 +783,6 @@ class Executor { instance: inst, suspensionMode: mode, stats: this.stats, - trapState: this.trapState, syncCallStack: this.syncCallStack, allInstances: () => this.componentInstances.values(), }); @@ -854,7 +861,6 @@ class Executor { core, stats: this.stats, suspensionMode: this.noteEntry(), - trapState: this.trapState, syncCallStack: this.syncCallStack, allInstances: () => this.componentInstances.values(), // Async-typed exports only; see `InstantiateInput.trapOnIdle`. @@ -874,7 +880,6 @@ class Executor { core, stats: this.stats, suspensionMode: "plain", - trapState: this.trapState, syncCallStack: this.syncCallStack, allInstances: () => this.componentInstances.values(), // A synchronous caller cannot await entry-hop quiescence; @@ -1118,6 +1123,14 @@ class Executor { } const fn = createTrampoline(decl, { componentInstance: (i) => this.componentInstance(i), + runtimeTable: (i) => { + const table = this.tables[i]; + if (table === undefined) { + throw new PlanError(`runtime table ${i} accessed before extraction`); + } + return table; + }, + enterThreadFunction: (fn) => enterWasm(fn as never, this.suspensionMode), resourceToken: (i) => { const token = this.loaded.resourceTokens[i]; if (token === undefined) { @@ -1218,7 +1231,7 @@ class Executor { calleeCanBlock: (fn: unknown) => this.suspendableFuncs.has(fn as object), syncCallStack: this.syncCallStack, factStartScopes: this.factStartScopes, - trapState: this.trapState, + trapScope: this.trapScope, 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 f55ef0fb..202990ea 100644 --- a/runtime/src/intrinsics/async_builtins.ts +++ b/runtime/src/intrinsics/async_builtins.ts @@ -31,6 +31,7 @@ import type { CoreValue, ValType } from "../cabi/types.ts"; import { valTypesEqual } from "../cabi/types.ts"; import { currentTask, + currentTaskThreadForInstance, currentThread, EventCode, type EventTuple, @@ -85,7 +86,9 @@ export function createTaskReturn( declaredInst !== undefined && !declaredInst.mayLeave, "task.return: cannot leave component instance (may_leave violation)", ); - const task = currentTask() as Task; + const task = declaredInst === undefined + ? currentTask() as Task + : currentTaskThreadForInstance(declaredInst).task; trapIf( !task.inst.mayLeave, "task.return: cannot leave component instance (may_leave violation)", @@ -453,11 +456,16 @@ export function createSubtaskCancel( | null; const store = inst.store as unknown as { waiting: { task?: unknown }[]; + awaiting: Set<{ task?: unknown; activePark?: unknown }>; }; const determinate = (): boolean => callee === null || callee.threads.every((th) => th.done()) || store.waiting.some((w) => w.task === st.calleeTask); + const calleeContinuationPending = (): boolean => + [...store.awaiting].some((t) => + t.task === st.calleeTask && t.activePark === null + ); // The SYNC form additionally blocks until the callee actually // resolves (definitions.py `canon_subtask_cancel`: // `thread.wait_until(subtask.resolved)`), then reports the resolved @@ -482,6 +490,13 @@ export function createSubtaskCancel( task: currentTask(), readyFunc: ready, cancellable: false, + // First finish the exact cancelled callee's pending JSPI + // continuation. Only after that canonical return/park boundary can + // the synchronous form's unresolved wait become CM blocking. + blockReason: "mandatory-continuation", + syncBlockWhen: () => + !async_ && determinate() && !calleeContinuationPending() && + !st.resolved(), produce: () => { st.hasSyncWaiter = false; return st.resolved() ? finish() : BLOCKED; diff --git a/runtime/src/intrinsics/fact_calls.ts b/runtime/src/intrinsics/fact_calls.ts index 5fada063..a64614ee 100644 --- a/runtime/src/intrinsics/fact_calls.ts +++ b/runtime/src/intrinsics/fact_calls.ts @@ -58,6 +58,7 @@ import { type Cancelled, type ComponentInstanceState, currentTask, + dbgId, entryRefusal, maybeCurrentTask, NeedsJspi, @@ -749,7 +750,9 @@ export function createAsyncStartCall( onProgress = () => subtask.setSubtaskPendingEvent(subtaski); const packed = packSubtaskResult(subtask.state, subtaski); traceCopy( - `async-start-call -> state=${subtask.state} i=${subtaski} ` + + `async-start-call task=${ + dbgId(task) + } -> state=${subtask.state} i=${subtaski} ` + `packed=0x${(packed as number).toString(16)}`, ); return packed; @@ -779,13 +782,24 @@ export function createAsyncStartCall( !subtask.resolved() && !thread.done() && thread.waiting(); + const engineOnlyExclusiveHop = (): boolean => { + const holder = prepared.calleeInst.exclusiveThread; + return holder !== null && holder.task !== task && + holder.awaiting !== null && holder.activePark === null; + }; + const entryGateIsGenuine = (): boolean => + prepared.calleeInst.backpressure > 0 || + (prepared.calleeInst.exclusiveThread !== null && + prepared.calleeInst.exclusiveThread.activePark !== null && + prepared.calleeInst.exclusiveThread.activePark.boundaryReturned === + true); const determinate = (): boolean => gatedAtEntry() // canon_lower reports STARTING as soon as the new callee blocks on // admission. Running an unrelated exclusive holder here can make // the new call complete before its caller observes it, contrary to // definitions.py:2257-2282 and task-builtins.wast:530-532. - ? true + ? !engineOnlyExclusiveHop() && entryGateIsGenuine() : subtask.resolved() || thread.done() || store.waiting.some((w) => w.task === task); @@ -799,7 +813,14 @@ export function createAsyncStartCall( task: currentTask(), readyFunc: determinate, cancellable: false, + // Determining async-start status first finishes the named callee's + // mandatory entry continuation. It is not itself a synchronous + // Component Model wait by the caller. + blockReason: "mandatory-continuation", produce: () => { + // Re-check at delivery: an engine continuation can replace the + // exclusive holder between readiness and this produce callback. + assert_(!gatedAtEntry() || entryGateIsGenuine()); const r = report(); produced = true; return r; diff --git a/runtime/src/intrinsics/mod.ts b/runtime/src/intrinsics/mod.ts index 0521254c..b9c8b148 100644 --- a/runtime/src/intrinsics/mod.ts +++ b/runtime/src/intrinsics/mod.ts @@ -80,28 +80,29 @@ import { type TranscodeMemory, type TranscodeOp, } from "./transcode.ts"; +import { + createThreadIndex, + createThreadNewIndirect, + createThreadResumeLater, + createThreadSuspend, + createThreadSuspendThenPromote, + createThreadSuspendThenResume, + createThreadYieldThenPromote, + createThreadYieldThenResume, + type ThreadTrampolineContext, +} from "./thread_builtins.ts"; +import { + raiseComponentBoundaryTrap, + rethrowComponentBoundaryTrap, + takeComponentBoundaryTrap, +} from "../task/mod.ts"; export * from "./transcode.ts"; export * from "./context.ts"; export * from "./async_builtins.ts"; export * from "./fact_calls.ts"; export * from "./stream_builtins.ts"; - -/** - * Where a host trap thrown *inside* a FACT adapter is remembered. - * - * FACT's `enter_exception_barrier` (`fact/trampoline.rs`) converts escaping - * exceptions to `UncaughtException`. Host traps are JS exceptions too, so we - * remember and restore them to preserve their cause across nested barriers. - * A guest exception with no pending host trap keeps the generic message. - * - * Limitation: this preserves diagnostics, not uncatchability. A guest's own - * `try_table catch_all` can catch a host trap and continue, contrary to the - * Component Model's trap semantics. No out-of-band mechanism prevents that. - */ -export interface HostTrapState { - pending: unknown; -} +export * from "./thread_builtins.ts"; /** * Trap-code → message, from wasmtime-environ `trap_encoding.rs` @@ -126,9 +127,6 @@ const FACT_TRAP_MESSAGES: Record = { 49: "uncaught exception propagated out of component", }; -/** Ordinal of `Trap::UncaughtException` in wasmtime's trap encoding. */ -const TRAP_UNCAUGHT_EXCEPTION = 49; - export { UnsupportedFeatureError } from "./errors.ts"; /** Diagnostic capability category for unsupported trampoline kinds. */ @@ -215,7 +213,13 @@ export interface FactStartScope { /** Executor services a trampoline body needs (provided by executor.ts). */ export interface TrampolineContext { + /** Per-instantiation trap owner for core start functions without a Task. */ + trapScope: object; componentInstance(index: number): ComponentInstanceState; + /** Extracted RuntimeTableIndex used by `thread.new-indirect`. */ + runtimeTable(index: number): WebAssembly.Table; + /** Wrap a table function as an independent JSPI-capable wasm entry. */ + enterThreadFunction(fn: CoreFn): CoreFn; resourceToken(index: number): ResourceTableInfo; /** * The component instance that *owns* resource table `index` @@ -244,8 +248,6 @@ export interface TrampolineContext { factStartScopes: FactStartScope[]; /** See `FactCallContext.calleeCanBlock` (intrinsics/fact_calls.ts). */ calleeCanBlock?(fn: unknown): boolean; - /** See `HostTrapState`. */ - trapState: HostTrapState; /** * Resolved canonical options by index, and the element types of an interned * results tuple — needed by the async built-ins (task.return, @@ -309,18 +311,17 @@ export function createTrampoline( ctx: TrampolineContext, ): CoreFn { const fn = createTrampolineBody(decl, ctx); - // Preserve host-trap diagnostics across the FACT exception barrier - // (see `HostTrapState`). This wraps the `trap` trampoline too, which is - // what keeps a specific trap specific across *nested* adapters: the inner - // barrier's `trap` trampoline restores and rethrows the real trap, this - // wrapper re-records it, and the outer barrier restores it again instead - // of reporting the generic `UncaughtException`. + // JS exceptions are catchable by a guest `try_table catch_all`. Replace + // them with a native core trap while retaining the cause on this activation. return (...args: unknown[]) => { try { return fn(...args); } catch (e) { - ctx.trapState.pending = e; - throw e; + // FACT may catch a carrier from an inner adapter and call its own trap + // trampoline. Transfer that exact semantic cause to the new carrier. + const carried = takeComponentBoundaryTrap(e); + if (carried !== undefined) rethrowComponentBoundaryTrap(carried); + raiseComponentBoundaryTrap(e, ctx.trapScope); } }; } @@ -388,16 +389,6 @@ function createTrampolineBody( // named `runtime.trap`; contracts/plan-format.md "trap" trampoline). const code = (decl as Extract).code; return () => { - if (code === TRAP_UNCAUGHT_EXCEPTION) { - const pending = ctx.trapState.pending; - if (pending !== undefined) { - // Deliberately *not* cleared: an enclosing adapter's barrier will - // catch this rethrow and needs to restore the same trap. The slot - // is reset per lifted-export call (exec/boundary.ts), which is - // what bounds its lifetime. - throw pending; - } - } const message = FACT_TRAP_MESSAGES[code]; trap( message === undefined @@ -640,6 +631,40 @@ function createTrampolineBody( ctx.suspensionMode, declaredInstance(decl, ctx), ); + case "thread-index": + return createThreadIndex(decl as never, ctx as ThreadTrampolineContext); + case "thread-new-indirect": + return createThreadNewIndirect( + decl as Extract, + ctx as ThreadTrampolineContext, + ); + case "thread-resume-later": + return createThreadResumeLater( + decl as never, + ctx as ThreadTrampolineContext, + ); + case "thread-suspend": + return createThreadSuspend(decl as never, ctx as ThreadTrampolineContext); + case "thread-suspend-then-resume": + return createThreadSuspendThenResume( + decl as never, + ctx as ThreadTrampolineContext, + ); + case "thread-yield-then-resume": + return createThreadYieldThenResume( + decl as never, + ctx as ThreadTrampolineContext, + ); + case "thread-suspend-then-promote": + return createThreadSuspendThenPromote( + decl as never, + ctx as ThreadTrampolineContext, + ); + case "thread-yield-then-promote": + return createThreadYieldThenPromote( + decl as never, + ctx as ThreadTrampolineContext, + ); // --- FACT cross-component calls (see ./fact_calls.ts) ----------------- case "prepare-call": diff --git a/runtime/src/intrinsics/thread_builtins.ts b/runtime/src/intrinsics/thread_builtins.ts new file mode 100644 index 00000000..562b2623 --- /dev/null +++ b/runtime/src/intrinsics/thread_builtins.ts @@ -0,0 +1,314 @@ +// Explicit Component Model thread built-ins. Semantics follow +// definitions.py `canon_thread_*` and CanonicalABI.md §§thread.index– +// thread.yield-then-promote. + +import { assert_, trapIf } from "../cabi/trap.ts"; +import type { CoreValue } from "../cabi/types.ts"; +import { awaitCore, type CoreFn } from "../exec/boundary.ts"; +import { blockCurrentActivation, type SuspensionMode } from "../jspi/mod.ts"; +import { + type Cancelled, + currentThreadExactlyForInstance, + needsJspi, + Thread, +} from "../task/mod.ts"; +import type { ComponentInstanceState } from "../task/mod.ts"; + +export interface ThreadTrampolineContext { + componentInstance(index: number): ComponentInstanceState; + runtimeTable(index: number): WebAssembly.Table; + suspensionMode: SuspensionMode; + enterThreadFunction(fn: CoreFn): CoreFn; +} + +type ThreadDecl = { instance: number; cancellable?: boolean }; + +// Generated from the checked WAT below. `ref.test` checks a nominal final +// reference without executing the target. It covers the canonical final types +// emitted by the current translator, but not every structurally equivalent +// non-final/derived type allowed by the Component Model. The JS API has no +// signature/reflection operation with which to implement that general check; +// contracts/intrinsics.md §B records capability restriction #12. +// +// (module +// (type $i32void (func (param i32))) +// (type $i64void (func (param i64))) +// (func (export "is-i32") (param funcref) (result i32) +// local.get 0 ref.test (ref $i32void)) +// (func (export "is-i64") (param funcref) (result i32) +// local.get 0 ref.test (ref $i64void))) +const THREAD_FUNC_VALIDATOR_BYTES = new Uint8Array([ + 0, + 97, + 115, + 109, + 1, + 0, + 0, + 0, + 1, + 14, + 3, + 96, + 1, + 127, + 0, + 96, + 1, + 126, + 0, + 96, + 1, + 112, + 1, + 127, + 3, + 3, + 2, + 2, + 2, + 7, + 19, + 2, + 6, + 105, + 115, + 45, + 105, + 51, + 50, + 0, + 0, + 6, + 105, + 115, + 45, + 105, + 54, + 52, + 0, + 1, + 10, + 17, + 2, + 7, + 0, + 32, + 0, + 251, + 20, + 0, + 11, + 7, + 0, + 32, + 0, + 251, + 20, + 1, + 11, +]); + +type RefValidator = (fn: CoreFn | null) => number; +let validators: { i32: RefValidator; i64: RefValidator } | undefined; + +function threadValidators(): { i32: RefValidator; i64: RefValidator } { + if (validators !== undefined) return validators; + const exports = new WebAssembly.Instance( + new WebAssembly.Module(THREAD_FUNC_VALIDATOR_BYTES), + ).exports; + validators = { + i32: exports["is-i32"] as RefValidator, + i64: exports["is-i64"] as RefValidator, + }; + return validators; +} + +function declaredInst( + decl: ThreadDecl, + ctx: ThreadTrampolineContext, +): ComponentInstanceState { + return ctx.componentInstance(decl.instance); +} + +function requireMayLeave(inst: ComponentInstanceState, what: string): void { + trapIf(!inst.mayLeave, `${what}: cannot leave component instance`); +} + +function requireTarget(inst: ComponentInstanceState, index: number): Thread { + const target = inst.threads.get(index >>> 0); + trapIf( + target === currentThreadExactlyForInstance(inst), + "cannot resume the current thread", + ); + return target; +} + +export function createThreadIndex( + decl: ThreadDecl, + ctx: ThreadTrampolineContext, +): CoreFn { + const inst = declaredInst(decl, ctx); + return () => { + requireMayLeave(inst, "thread.index"); + const thread = currentThreadExactlyForInstance(inst); + assert_(thread.index !== null, "current thread is not registered"); + return thread.index; + }; +} + +export function createThreadNewIndirect( + decl: ThreadDecl & { startFuncTable: number }, + ctx: ThreadTrampolineContext, +): CoreFn { + const inst = declaredInst(decl, ctx); + const table = ctx.runtimeTable(decl.startFuncTable); + return (rawIndex?: number, closure?: CoreValue) => { + requireMayLeave(inst, "thread.new-indirect"); + const task = currentThreadExactlyForInstance(inst).task; + const index = (rawIndex ?? 0) >>> 0; + let target: CoreFn | null; + try { + target = table.get(index) as CoreFn | null; + } catch { + trapIf(true, "thread.new-indirect table index out of range"); + return 0; + } + trapIf(target === null, "thread.new-indirect function is null"); + assert_(target !== null); + + // CONTRACT: the validated canonical trampoline signature determines the + // declared start parameter: i32 arrives as number and i64 as bigint. The + // nominal ref.test check is deliberately the documented supported subset, + // not a claim of full structural CoreFuncType equality (contract §B/#12). + trapIf( + typeof closure !== "number" && typeof closure !== "bigint", + "thread.new-indirect invalid closure type", + ); + const expected = typeof closure === "bigint" ? "i64" : "i32"; + trapIf( + threadValidators()[expected](target) === 0, + "thread.new-indirect function type mismatch", + ); + const entry = ctx.enterThreadFunction(target); + const holder: { thread?: Thread } = {}; + const body = (function* () { + const thread = holder.thread!; + try { + yield* awaitCore(entry, [closure!] as CoreValue[], thread); + } catch (error) { + task.abortThread(thread); + throw error; + } + if (thread.index !== null) { + task.unregisterThread(thread); + // An explicit thread may be the activation which resolved the task; + // public delivery becomes eligible when that activation returns. + task.controlReturned(thread); + } + })(); + const thread = new Thread(task, body); + holder.thread = thread; + task.registerThread(thread); + return thread.index!; + }; +} + +export function createThreadResumeLater( + decl: ThreadDecl, + ctx: ThreadTrampolineContext, +): CoreFn { + const inst = declaredInst(decl, ctx); + return (index?: number) => { + requireMayLeave(inst, "thread.resume-later"); + const target = requireTarget(inst, index ?? 0); + trapIf(!target.explicitlySuspended(), "cannot resume thread"); + target.resumeLater(); + }; +} + +type ParkKind = "suspend" | "yield"; +type TargetKind = "none" | "resume" | "promote"; + +function createThreadPark( + decl: ThreadDecl, + ctx: ThreadTrampolineContext, + park: ParkKind, + targetKind: TargetKind, +): CoreFn { + const inst = declaredInst(decl, ctx); + const cancellable = decl.cancellable === true; + return (index?: number) => { + requireMayLeave(inst, `thread.${park}`); + const caller = currentThreadExactlyForInstance(inst); + + let target: Thread | null = null; + if (targetKind !== "none") { + target = requireTarget(inst, index ?? 0); + if (targetKind === "resume") { + trapIf(!target.explicitlySuspended(), "cannot resume thread"); + } + } + // definitions.py validates target/self/state before cancellation delivery. + if (caller.task.deliverPendingCancel(cancellable)) return 1; + if (ctx.suspensionMode !== "jspi") { + needsJspi( + `thread.${park}${targetKind === "none" ? "" : `-then-${targetKind}`}`, + ); + } + + const targetSchedulable = target?.schedulable() ?? null; + const resumeTarget = targetKind === "resume" || + (targetKind === "promote" && targetSchedulable?.ready() === true); + const callerPromise = blockCurrentActivation({ + store: inst.store, + task: caller.task, + readyFunc: park === "yield" ? () => true : null, + cancellable, + explicitSuspend: park === "suspend", + produce: (cancelled: Cancelled) => cancelled ? 1 : 0, + }); + if (resumeTarget) { + // The caller's canonical state is now published. Transfer directly to + // the named target before any ordinary scheduler choice. + if (target!.explicitlySuspended()) target!.resumeLater(); + target!.schedulable().resume(); + } + return callerPromise as unknown as number; + }; +} + +export function createThreadSuspend( + decl: ThreadDecl, + ctx: ThreadTrampolineContext, +): CoreFn { + return createThreadPark(decl, ctx, "suspend", "none"); +} + +export function createThreadSuspendThenResume( + decl: ThreadDecl, + ctx: ThreadTrampolineContext, +): CoreFn { + return createThreadPark(decl, ctx, "suspend", "resume"); +} + +export function createThreadYieldThenResume( + decl: ThreadDecl, + ctx: ThreadTrampolineContext, +): CoreFn { + return createThreadPark(decl, ctx, "yield", "resume"); +} + +export function createThreadSuspendThenPromote( + decl: ThreadDecl, + ctx: ThreadTrampolineContext, +): CoreFn { + return createThreadPark(decl, ctx, "suspend", "promote"); +} + +export function createThreadYieldThenPromote( + decl: ThreadDecl, + ctx: ThreadTrampolineContext, +): CoreFn { + return createThreadPark(decl, ctx, "yield", "promote"); +} diff --git a/runtime/src/jspi/bridge.ts b/runtime/src/jspi/bridge.ts index 3abaabf3..527b241b 100644 --- a/runtime/src/jspi/bridge.ts +++ b/runtime/src/jspi/bridge.ts @@ -45,18 +45,23 @@ import { } from "./mechanics.ts"; import { claimActivationAmbient, + componentBoundaryTrapCarrier, dbgId, maybeCurrentThread, physicalOwnerOf, releaseActivationAmbient, + rethrowComponentBoundaryTrap, + takeComponentBoundaryTrap, withActivation, } from "../task/mod.ts"; import type { Cancelled, CurrentThreadLike, + RequiredSyncPark, SchedulableThread, Store, } from "../task/mod.ts"; +import type { Thread, ThreadPark } from "../task/thread.ts"; /** Which suspension discipline an instantiation runs under. */ export type SuspensionMode = "plain" | "jspi"; @@ -110,6 +115,11 @@ export function trampolineNeedsSuspension( case "sync-start-call": case "waitable-set-wait": case "thread-yield": + case "thread-suspend": + case "thread-suspend-then-resume": + case "thread-yield-then-resume": + case "thread-suspend-then-promote": + case "thread-yield-then-promote": return true; case "subtask-cancel": case "stream-cancel-read": @@ -265,9 +275,12 @@ export function setContinuationOwner( continuationOwners.set(promise, owner); } -/** Queue attribution before the wrapped Promise settles. Engine continuation - * timing need not make the sentinel and wasm chunk adjacent; instance-scoped - * ambient lookup also filters sibling-instance claims. */ +/** Queue attribution before the wrapped Promise settles. Rejections are first + * replaced by a native-Wasm trap tied to the captured continuation owner: JSPI + * preserves that RuntimeError's identity and resumes Wasm with an uncatchable + * trap, whereas rejecting with an ordinary JS value is caught by `catch_all`. + * Engine continuation timing need not make the sentinel and wasm chunk + * adjacent; instance-scoped ambient lookup also filters sibling claims. */ function attributeContinuation( owner: unknown, r: PromiseLike, @@ -281,7 +294,16 @@ function attributeContinuation( return v; }, (e) => { - sentinelFor(continuationOwners.get(r as object) ?? owner); + const continuation = continuationOwners.get(r as object) ?? owner; + sentinelFor(continuation); + if (continuation !== null && continuation !== undefined) { + const carried = takeComponentBoundaryTrap(e); + if (carried !== undefined) rethrowComponentBoundaryTrap(carried); + throw componentBoundaryTrapCarrier( + e, + continuation as CurrentThreadLike, + ); + } throw e; }, ); @@ -403,7 +425,8 @@ const SP_TRACE = (() => { } })(); -export class SuspensionPoint implements SchedulableThread { +export class SuspensionPoint + implements SchedulableThread, ThreadPark { readonly promise: Promise; #settle!: (v: T) => void; #fail!: (e: unknown) => void; @@ -429,6 +452,15 @@ export class SuspensionPoint implements SchedulableThread { readonly owner: any; /** Canonical task/context identity active inside the physical continuation. */ readonly logicalOwner: CurrentThreadLike | null; + /** Whether this is spec-level blocking or JS host latency accommodated by + * the JSPI embedding. Only the former participates in sync-callee driving. */ + readonly blockReason: + | "component-model" + | "host-import" + | "mandatory-continuation"; + readonly #syncBlockWhen?: () => boolean; + readonly #explicitSuspend: boolean; + #explicitReady = false; constructor( store: Store, @@ -470,10 +502,22 @@ export class SuspensionPoint implements SchedulableThread { * * it must not resume/abandon this or any other suspension point. */ private readonly onSettled?: () => void, + blockReason: + | "component-model" + | "host-import" + | "mandatory-continuation" = "component-model", + explicitSuspend = false, + syncBlockWhen?: () => boolean, ) { this.#store = store; this.owner = owner ?? maybeCurrentThread() ?? task?.implicitThread ?? null; this.logicalOwner = logicalOwner ?? this.owner; + this.blockReason = blockReason; + this.#syncBlockWhen = syncBlockWhen; + this.#explicitSuspend = explicitSuspend; + if (this.logicalOwner !== null && "activePark" in this.logicalOwner) { + (this.logicalOwner as Thread).activePark = this; + } if (SP_TRACE) { console.error( `[sp] mint ${dbgId(this)} owner=${dbgId(this.owner)} task=${ @@ -495,19 +539,47 @@ export class SuspensionPoint implements SchedulableThread { ready(): boolean { if (this.#done) return false; + if (this.#explicitReady) return true; if (this.readyFunc !== null && this.readyFunc()) return true; // definitions.py `ready_or_cancelled` (`Thread.wait_until` line 369), // ported to task/thread.ts:waitUntil: a cancel that arrived while this // task was not cancellable (parked as `pending-cancel`) makes the block // point ready on its own, otherwise the wakeup is lost until some // unrelated event happens to satisfy `readyFunc` — possibly never. - // A `SuspensionPoint` is a frame OF the implicit thread, so the "and the - // lock is free" conjunct (`Task.implicitThreadCancellable`, the live - // `lock_available` of the reference's callback loop) applies here - // unconditionally — the same exclusion `Task.requestCancellation` puts on - // its scan of `store.waiting`. - return this.cancellable && this.#taskHasPendingCancel() && - this.task.implicitThreadCancellable() === true; + // Only the implicit callback thread is gated by callback exclusivity. + // Explicit sibling threads remain cancellation candidates independently. + const lockAvailable = this.logicalOwner === this.task?.implicitThread + ? this.task.implicitThreadCancellable() === true + : true; + return this.cancellable && this.#taskHasPendingCancel() && lockAvailable; + } + + explicitlySuspended(): boolean { + return this.#explicitSuspend && !this.#explicitReady && !this.#done; + } + + refreshSyncRequirement(): void { + if (!this.boundaryReturned || !this.waiting()) return; + const logicalTask = this.logicalOwner?.task; + const root = logicalTask?.failureOwner; + const rootLive = root?.inst?.activeCalls instanceof Set + ? root.inst.activeCalls.has(root) + : true; + const semanticBlock = this.blockReason === "component-model" || + (this.blockReason === "mandatory-continuation" && + this.#syncBlockWhen?.() === true); + if (semanticBlock && logicalTask?.ft?.async === false && rootLive) { + this.#store.requireSyncProgress(this as unknown as RequiredSyncPark); + } + } + + explicitResumeLater(): void { + assert_( + this.explicitlySuspended(), + "resume_later on a non-suspended thread", + ); + this.#explicitReady = true; + this.#store.requestService(); } /** @@ -642,6 +714,13 @@ export class SuspensionPoint implements SchedulableThread { #finish(): void { if (this.#finished) return; this.#finished = true; + if ( + this.logicalOwner !== null && "activePark" in this.logicalOwner && + (this.logicalOwner as Thread).activePark === this + ) { + (this.logicalOwner as Thread).activePark = null; + } + this.#store.finishSyncProgress(this as unknown as RequiredSyncPark); if (this.onSettled === undefined) return; try { this.onSettled(); @@ -677,6 +756,16 @@ export function blockCurrentActivation(input: { * settle paths that never call `produce` (issue #102). */ onSettled?: () => void; + /** Host Promise latency is an embedding wait, not Component Model blocking. */ + blockReason?: + | "component-model" + | "host-import" + | "mandatory-continuation"; + /** A mandatory continuation can become a genuine CM wait after its named + * dependency reaches a canonical return/park boundary. */ + syncBlockWhen?: () => boolean; + /** This park is the resumable state of a `thread.suspend*` operation. */ + explicitSuspend?: boolean; }): Promise { // GATE LIFETIME: pristine reference semantics (definitions.py // `block_internal` line 378 does NOT touch `inst.exclusive_thread`). A @@ -715,9 +804,18 @@ export function blockCurrentActivation(input: { owner, logicalOwner, input.onSettled, + input.blockReason, + input.explicitSuspend, + input.syncBlockWhen, ); Promise.resolve().then(() => { point.boundaryReturned = true; + // CONTRACT: a sync-typed logical `canon_lift` must run the callee + // instance's ready threads and trap if none can progress + // (definitions.py:2186-2194). Capture the actual logical activation at + // park construction; outer-driver idle policy and unrelated host retention + // are not semantic evidence for this decision. + point.refreshSyncRequirement(); if ( point.waiting() && owner?.awaiting !== null && owner?.awaiting !== undefined @@ -728,7 +826,6 @@ export function blockCurrentActivation(input: { // an engine-only entry hop. Same-instance admission observes completed // scheduler state changes, not coalesced requests, so publish this park // transition before asking the coordinator to service newly exposed work. - input.store.noteServiceProgress(); input.store.requestService(); }); return point.promise; diff --git a/runtime/src/plan/format.ts b/runtime/src/plan/format.ts index bc095989..1e12b809 100644 --- a/runtime/src/plan/format.ts +++ b/runtime/src/plan/format.ts @@ -154,6 +154,13 @@ export type WireTrampoline = index: number; instance: number; } + | { + kind: "thread-new-indirect"; + index: number; + instance: number; + startFuncType: number; + startFuncTable: number; + } | { kind: | "thread-suspend" diff --git a/runtime/src/task/mod.ts b/runtime/src/task/mod.ts index b4c67574..9dad9207 100644 --- a/runtime/src/task/mod.ts +++ b/runtime/src/task/mod.ts @@ -142,7 +142,6 @@ export class Task { * trampoline may lack a mapping; only known types may be compared. */ factResultTypesKnown = false; - /** * Host-call lifecycle hooks. `onControlReturn` is deliberately separate * from `onResolve`: definitions.py delivers canonical resolution inside @@ -283,6 +282,19 @@ export class Task { thread.index = null; } + /** Exceptional explicit-thread exit. Remove scheduler/table membership + * without applying unregisterThread's successful last-thread resolution + * check; the original escaping trap remains authoritative. */ + abortThread(thread: Thread): void { + const i = this.threads.indexOf(thread); + if (i !== -1) this.threads.splice(i, 1); + if (thread.index !== null) { + this.inst.threads.remove(thread.index); + thread.index = null; + } + if (thread.waiting()) this.inst.store.stopWaiting(thread); + } + /** * definitions.py `Task.request_cancellation`. Delivered to a * cancellable thread if one exists; otherwise recorded as pending, to be @@ -315,18 +327,28 @@ export class Task { if (excludeImplicit) { candidates = candidates.filter((t) => t !== this.implicitThread); } - // The implicit thread's SuspensionPoints obey the same exclusivity test. - if (!excludeImplicit) { - const store = this.inst.store as unknown as { - waiting: ({ task?: unknown } & Cancellable)[]; - }; - for (const w of store.waiting) { - if ( - w.task === this && w.cancellable === true && - !candidates.includes(w) - ) { - candidates.push(w); - } + // Explicit-thread SuspensionPoints are independent of the callback lock; + // only the implicit thread's point is excluded while another thread holds it. + const store = this.inst.store as unknown as { + waiting: ({ + task?: unknown; + logicalOwner?: unknown; + owner?: unknown; + } & Cancellable)[]; + }; + for (const w of store.waiting) { + // A SuspensionPoint names its canonical recipient as logicalOwner; older + // low-level points may only expose physical owner. Plain Thread entries + // are their own recipient. Normalize before applying the callback lock so + // the implicit thread cannot be filtered above and re-added here merely + // because its direct waiting entry has no logicalOwner field. + const recipient = w.logicalOwner ?? w.owner ?? w; + if ( + w.task === this && w.cancellable === true && + (!excludeImplicit || recipient !== this.implicitThread) && + !candidates.includes(w) + ) { + candidates.push(w); } } // Poisoned instances cannot run a cancellation recipient. @@ -446,6 +468,12 @@ export class SynchronousActivation { () => [], () => {}, ); + // A logical FACT callee has no host-visible completion channel of its own. + // Preserve the root call identity captured at entry so a later genuine + // park can distinguish an already-published result from a live sync call. + // See definitions.py:2186-2194 for the synchronous callee drive. + this.task.failureOwner = (parent?.task?.failureOwner ?? parent?.task ?? + this.task) as Task; this.thread = new Thread(this.task, (function* () {})()); const physical = this.parent === undefined ? this.thread diff --git a/runtime/src/task/scheduler.ts b/runtime/src/task/scheduler.ts index 7eb02bff..bef857c7 100644 --- a/runtime/src/task/scheduler.ts +++ b/runtime/src/task/scheduler.ts @@ -300,6 +300,9 @@ export function chooseCandidate(candidates: readonly T[]): T { return candidates[nextRandom() % candidates.length]; } +/** Deterministic direct-switch selection. Unlike ordinary ready scheduling, + * `thread.*-then-resume` names its target and is not a policy choice. */ + // --------------------------------------------------------------------------- // Current-thread context (definitions.py `current_thread`) // --------------------------------------------------------------------------- @@ -333,6 +336,120 @@ export interface CurrentThreadLike { }; } +// A native core-Wasm trap crosses an imported-JS frame without becoming a +// catchable Wasm exception. Component traps cannot be represented by throwing a +// JS value: `try_table catch_all` catches those. Keep the semantic cause on the +// physical activation and use this function only as the uncatchable carrier. +const NATIVE_COMPONENT_TRAP = (() => { + const bytes = new Uint8Array([ + 0x00, + 0x61, + 0x73, + 0x6d, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x04, + 0x01, + 0x60, + 0x00, + 0x00, + 0x03, + 0x02, + 0x01, + 0x00, + 0x07, + 0x05, + 0x01, + 0x01, + 0x66, + 0x00, + 0x00, + 0x0a, + 0x05, + 0x01, + 0x03, + 0x00, + 0x00, + 0x0b, + ]); + return new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports + .f as () => never; +})(); +export interface ComponentTrapRecord { + cause: unknown; + owner: object; + logicalOwner?: CurrentThreadLike; +} +const componentTrapCarriers = new WeakMap(); + +/** Raise a Component Model trap through guest Wasm without exposing a + * catchable JS exception. `fallbackOwner` attributes core start-function traps + * without publishing a fake currentTask(). */ +export function raiseComponentBoundaryTrap( + cause: unknown, + fallbackOwner?: object, +): never { + const current = maybeCurrentThread(); + const origin = current ?? fallbackOwner; + if (origin === undefined) throw cause; + throw componentBoundaryTrapCarrier(cause, origin); +} + +/** Re-emit an attributed cause without consulting the current ambient. */ +export function rethrowComponentBoundaryTrap( + record: ComponentTrapRecord, +): never { + throw componentBoundaryTrapCarrierForRecord(record); +} + +/** Construct the native trap carrier when the semantic decision is made by a + * scheduler turn rather than on the guest's live JS stack. */ +export function componentBoundaryTrapCarrier( + cause: unknown, + current: object, +): unknown { + const logicalOwner = "storage" in current && "task" in current + ? current as CurrentThreadLike + : undefined; + const owner = logicalOwner === undefined + ? current + : physicalOwnerOf(logicalOwner); + return componentBoundaryTrapCarrierForRecord({ cause, owner, logicalOwner }); +} + +function componentBoundaryTrapCarrierForRecord( + record: ComponentTrapRecord, +): unknown { + try { + NATIVE_COMPONENT_TRAP(); + } catch (carrier) { + if (typeof carrier === "object" && carrier !== null) { + componentTrapCarriers.set(carrier, record); + } + return carrier; + } + throw new Error("native component trap unexpectedly returned"); +} + +/** Consume only an exact carrier identity. There is intentionally no + * owner-based fallback: a caught/abandoned carrier must not relabel a later, + * unrelated RuntimeError on the same activation. */ +export function takeComponentBoundaryTrap( + carrier?: unknown, +): ComponentTrapRecord | undefined { + if (typeof carrier === "object" && carrier !== null) { + const exact = componentTrapCarriers.get(carrier); + if (exact !== undefined) { + componentTrapCarriers.delete(carrier); + return exact; + } + } + return undefined; +} + export function pushCurrentThread(t: CurrentThreadLike): void { threadStack.push(t); } @@ -612,6 +729,28 @@ export function currentThreadForInstance( return currentThread(); } +/** Current activation for a declaration-owned instance. Unlike the generic + * fallback, this refuses a stale sibling claim from another instance. */ +export function currentThreadExactlyForInstance( + inst: unknown, +): T { + const t = resolveAmbientForInstance(inst); + if (t === undefined) { + throw new PendingCapability( + "task-scoped canonical built-in has no current thread for its declared instance", + ); + } + return t as T; +} + +/** Exact logical current thread for task-scoped built-ins. Synchronous nested + * activations in the same instance must retain their own task identity. */ +export function currentTaskThreadForInstance( + inst: unknown, +): T { + return currentThreadExactlyForInstance(inst); +} + function resolveAmbientForInstance( inst: unknown, ): CurrentThreadLike | undefined { @@ -662,6 +801,18 @@ export interface SchedulableThread { resume(cancelled?: Cancelled): void; // deno-lint-ignore no-explicit-any task: any; + /** Refresh a dynamic synchronous-lift obligation at a safe service boundary. */ + refreshSyncRequirement?(): void; +} + +/** A genuine Component Model park reached by a synchronous logical callee. + * The host driver must apply definitions.py's instance-local sync lift loop, + * rather than the outer export's async idle policy. */ +export interface RequiredSyncPark extends SchedulableThread { + abandon(reason: unknown): void; + waiting(): boolean; + readonly logicalOwner: CurrentThreadLike; + readonly owner: CurrentThreadLike; } /** A scheduler entry observed a failure from a specific task. The envelope is @@ -715,6 +866,26 @@ function originatedFailure( export class Store { readonly waiting: SchedulableThread[] = []; + /** Active sync-callee parks, in registration order. These are deliberately + * instance-local obligations, not global liveness/deadlock evidence. */ + readonly requiredSyncParks: RequiredSyncPark[] = []; + + refreshSyncRequirements(): void { + for (const waiting of this.waiting) waiting.refreshSyncRequirement?.(); + } + + requireSyncProgress(point: RequiredSyncPark): void { + if (!this.requiredSyncParks.includes(point)) { + this.requiredSyncParks.push(point); + this.requestService(); + } + } + + finishSyncProgress(point: RequiredSyncPark): void { + const i = this.requiredSyncParks.indexOf(point); + if (i !== -1) this.requiredSyncParks.splice(i, 1); + } + /** * Host-import promises this store is waiting on. Non-empty means progress * is possible but only after a microtask turn — see `drive` in @@ -732,34 +903,11 @@ export class Store { /** Installed by exec/boundary.ts. Scheduler transitions only announce that * work may now be runnable; one store coordinator owns asynchronous drain. */ serviceRequested: (() => void) | null = null; - #serviceProgressRevision = 0; - #serviceProgressObservers = new Set<() => void>(); requestService(): void { this.serviceRequested?.(); } - /** Record completed scheduler state change separately from a request to run - * the coordinator. Admission waiters use this generation so a coalesced - * request cannot wake them before the corresponding tail has executed. */ - noteServiceProgress(): void { - this.#serviceProgressRevision++; - const observers = [...this.#serviceProgressObservers]; - this.#serviceProgressObservers.clear(); - for (const resolve of observers) resolve(); - } - - serviceProgressRevision(): number { - return this.#serviceProgressRevision; - } - - waitForServiceProgress(after: number): Promise { - if (this.#serviceProgressRevision !== after) return Promise.resolve(); - return new Promise((resolve) => - this.#serviceProgressObservers.add(resolve) - ); - } - /** * Per-store scheduling gate, not an ambient source. Several resumptions * can be outstanding; `tick` waits until all entries are released, without @@ -901,9 +1049,13 @@ export class Store { * this step form so it can revalidate entry-hop permission after each guest * activation. */ - serviceSettledStep(): boolean { + serviceSettledStepFor(inst?: unknown): boolean { while (this.settled.length > 0) { - const s = this.settled.shift()!; + const index = inst === undefined + ? 0 + : this.settled.findIndex((s) => s.t?.task?.inst === inst); + if (index === -1) return false; + const [s] = this.settled.splice(index, 1); // Another driver already resumed this thread. if (!this.awaiting.has(s.t)) continue; const t = s.t as { @@ -925,15 +1077,18 @@ export class Store { throw originatedFailure(origin, e); } finally { // The awaiting identity may have been removed, re-parked, or retired. - // Notify state observers after that transition, not when service was - // merely requested. - this.noteServiceProgress(); + // The coordinator rechecks admission and sync obligations immediately + // after this complete canonical step. } return true; } return false; } + serviceSettledStep(): boolean { + return this.serviceSettledStepFor(); + } + /** Explicit callers that need all currently queued tails drain stepwise. */ serviceSettled(): boolean { let did = false; @@ -988,12 +1143,12 @@ export class Store { * when a pending resumption or queued tail must be serviced first. */ tick(): boolean { - // Let this store's settled suspensions reach their engine continuations - // before scheduling another thread. + const candidates = this.readyCandidates(); + // A direct thread switch is a canonical transfer, not an ordinary + // scheduling point. Its named target runs before unrelated settled tails + // or engine-hop claims can consume the scheduler turn. if (this.pendingResumptions.size > 0) return false; - // Finish queued bookkeeping before observing readiness. if (this.hasServiceableSettled()) return false; - const candidates = this.readyCandidates(); if (candidates.length === 0) return false; const thread = chooseCandidate(candidates); const inst = thread.task.inst; @@ -1015,6 +1170,29 @@ export class Store { } return true; } + + /** One definitions.py `canon_lift` sync-loop choice, restricted to the + * callee instance. The embedding driver separately admits engine-only tails. */ + tickForInstance(inst: unknown): boolean { + const candidates = this.readyCandidates().filter((t) => + t.task?.inst === inst + ); + if (candidates.length === 0) return false; + const thread = chooseCandidate(candidates); + const origin = thread.task?.failureOwner ?? thread.task; + try { + thread.resume(); + } catch (e) { + if (!(e instanceof NeedsJspi) && !(e instanceof PendingCapability)) { + notifyInstancePoisoned( + thread.task.inst as { handles: Iterable }, + e, + ); + } + throw originatedFailure(origin, e); + } + return true; + } } // --------------------------------------------------------------------------- diff --git a/runtime/src/task/thread.ts b/runtime/src/task/thread.ts index 0bc554b8..09c6a178 100644 --- a/runtime/src/task/thread.ts +++ b/runtime/src/task/thread.ts @@ -10,8 +10,8 @@ // thread.storage[2] storage: [0, 0] (context.{get,set}) // thread.index index (inst.threads table slot) // -// Shared-everything thread switching is not implemented (#12); resume drives -// one generator, while JSPI suspension is represented by the bridge. +// A generator owns each logical activation; JSPI SuspensionPoints represent +// parks inside its wasm entry and are linked through `explicitPark`. import { assert_ } from "../cabi/trap.ts"; import { @@ -34,11 +34,20 @@ import { type ThreadState = "running" | "suspended" | "waiting" | "done"; +/** The explicit-resume surface supplied by a JSPI SuspensionPoint. */ +export interface ThreadPark extends SchedulableThread { + readonly boundaryReturned?: boolean; + explicitResumeLater(): void; + explicitlySuspended(): boolean; +} + export class Thread implements SchedulableThread { /** Physical generator activation used for JSPI awaiting/resumption. */ physicalOwner: Thread = this; /** Persistent logical descendants, including while their stack is unpublished. */ readonly logicalDescendants: Set = new Set(); + /** Current wasm-level `thread.suspend*` park, if this activation has one. */ + activePark: ThreadPark | null = null; /** Present when this Thread is driven by an enclosing wasm sync call. */ logicalActivation?: { active: boolean; @@ -122,11 +131,29 @@ export class Thread implements SchedulableThread { /** definitions.py `Thread.resume_later`. */ resumeLater(): void { - assert_(this.suspended(), "resume_later on a non-suspended thread"); + if (this.activePark !== null) { + this.activePark.explicitResumeLater(); + return; + } + assert_( + this.suspended() && this.awaiting === null, + "resume_later on a non-suspended thread", + ); this.#startWaiting(() => true); this.#store.requestService(); } + /** Canonical suspended state accepted by `thread.*-then-resume`. */ + explicitlySuspended(): boolean { + return this.activePark?.explicitlySuspended() ?? + (this.suspended() && this.awaiting === null); + } + + /** Scheduler object whose readiness represents this logical thread. */ + schedulable(): SchedulableThread { + return this.activePark ?? this; + } + /** Pending `awaitValue` promise, if this thread is parked on one. */ awaiting: Promise | null = null; diff --git a/runtime/tests/boundary_trap_test.ts b/runtime/tests/boundary_trap_test.ts index c5f2f887..1929340c 100644 --- a/runtime/tests/boundary_trap_test.ts +++ b/runtime/tests/boundary_trap_test.ts @@ -7,7 +7,32 @@ import { assertEq } from "./support/asserts.ts"; import { Trap } from "../src/cabi/mod.ts"; -import { callCore } from "../src/exec/boundary.ts"; +import { + callCore, + createLiftedFunction, + createLoweredImport, + newStats, + type ResolvedOptions, +} from "../src/exec/boundary.ts"; +import { adaptHostFunction } from "../src/exec/host_settlement.ts"; +import { + createTrampoline, + type TrampolineContext, +} from "../src/intrinsics/mod.ts"; +import { + componentBoundaryTrapCarrier, + ComponentInstanceState, + raiseComponentBoundaryTrap, + Store, + takeComponentBoundaryTrap, + withActivation, +} from "../src/task/mod.ts"; +import { suspendingImport } from "../src/jspi/bridge.ts"; + +const WASM_JSPI = WebAssembly as unknown as { + promising?: (fn: () => number) => () => Promise; + Suspending?: abstract new (fn: () => unknown) => unknown; +}; /** A real `WebAssembly.Module` whose sole export unconditionally traps. */ function unreachableCoreFn(): (...args: unknown[]) => unknown { @@ -72,3 +97,366 @@ Deno.test("callCore: a real core `unreachable` trap surfaces as a Trap with the // normalization is the harness's job now, not the runtime's. assertEq((caught as Trap).message, "guest trapped: unreachable"); }); + +/** A guest catch_all around an imported function. Returns 1 only if the + * imported failure was incorrectly exposed as a catchable Wasm exception. */ +function guestCatcher(imported: () => void): () => number { + const bytes = new Uint8Array([ + 0x00, + 0x61, + 0x73, + 0x6d, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x02, + 0x60, + 0x00, + 0x00, + 0x60, + 0x00, + 0x01, + 0x7f, + 0x02, + 0x06, + 0x01, + 0x00, + 0x01, + 0x66, + 0x00, + 0x00, + 0x03, + 0x02, + 0x01, + 0x01, + 0x07, + 0x07, + 0x01, + 0x03, + 0x72, + 0x75, + 0x6e, + 0x00, + 0x01, + 0x0a, + 0x14, + 0x01, + 0x12, + 0x00, + 0x02, + 0x40, + 0x1f, + 0x40, + 0x01, + 0x02, + 0x00, + 0x10, + 0x00, + 0x41, + 0x00, + 0x0f, + 0x0b, + 0x0b, + 0x41, + 0x01, + 0x0b, + ]); + const mod = new WebAssembly.Module(bytes); + return new WebAssembly.Instance(mod, { "": { f: imported } }).exports + .run as () => number; +} + +Deno.test("component trap carrier bypasses guest catch_all and restores the activation-local cause", () => { + const owner: { + storage: number[]; + task: object; + } = { storage: [], task: {} }; + const expected = new Trap("specific component trap"); + const run = guestCatcher(() => raiseComponentBoundaryTrap(expected)); + let caught: unknown; + try { + withActivation(owner, () => callCore(run as never, [])); + } catch (e) { + caught = e; + } + assertEq(caught, expected); +}); + +Deno.test("component trap causes are isolated by physical activation", () => { + const a = { storage: [], task: {} }; + const b = { storage: [], task: {} }; + const causeA = new Trap("A"); + const causeB = new Trap("B"); + const runA = guestCatcher(() => raiseComponentBoundaryTrap(causeA)); + const runB = guestCatcher(() => raiseComponentBoundaryTrap(causeB)); + let caughtA: unknown; + let caughtB: unknown; + try { + withActivation(a, () => callCore(runA as never, [])); + } catch (e) { + caughtA = e; + } + try { + withActivation(b, () => callCore(runB as never, [])); + } catch (e) { + caughtB = e; + } + assertEq(caughtA, causeA); + assertEq(caughtB, causeB); +}); + +Deno.test("logical activations record the physical owner without losing their identity", () => { + const physical = { storage: [], task: {} }; + const logical = { storage: [], task: {}, physicalOwner: physical }; + const cause = new Trap("logical"); + const carrier = componentBoundaryTrapCarrier(cause, logical); + const recovered = takeComponentBoundaryTrap(carrier); + assertEq(recovered?.cause, cause); + assertEq(recovered?.owner, physical); + assertEq(recovered?.logicalOwner, logical); +}); + +Deno.test("actual trampoline rethrows A's carrier under ambient B without reattribution", () => { + const physicalA = { storage: [], task: {} }; + const logicalA = { storage: [], task: {}, physicalOwner: physicalA }; + const physicalB = { storage: [], task: {} }; + const logicalB = { storage: [], task: {}, physicalOwner: physicalB }; + const cause = new Trap("A cause"); + const carrierA = componentBoundaryTrapCarrier(cause, logicalA); + const trampoline = createTrampoline( + { kind: "lower-import", lowered: 0, options: 0, type: 0 } as never, + { + trapScope: {}, + loweredImport: () => () => { + throw carrierA; + }, + } as unknown as TrampolineContext, + ); + + let carrierB: unknown; + try { + withActivation(logicalB, () => trampoline()); + } catch (e) { + carrierB = e; + } + const recovered = takeComponentBoundaryTrap(carrierB); + assertEq(recovered?.cause, cause); + assertEq(recovered?.owner, physicalA); + assertEq(recovered?.logicalOwner, logicalA); + assertEq(takeComponentBoundaryTrap(carrierA), undefined); +}); + +Deno.test("carrier recovery is exact, order-independent, and supports undefined causes", () => { + const a = { storage: [], task: {} }; + const b = { storage: [], task: {} }; + const carrierA = componentBoundaryTrapCarrier(undefined, a); + const causeB = new Trap("B"); + const carrierB = componentBoundaryTrapCarrier(causeB, b); + + assertEq( + takeComponentBoundaryTrap(new WebAssembly.RuntimeError("unrelated")), + undefined, + ); + const recoveredB = takeComponentBoundaryTrap(carrierB); + const recoveredA = takeComponentBoundaryTrap(carrierA); + assertEq(recoveredB?.cause, causeB); + assertEq(recoveredB?.owner, b); + assertEq(recoveredB?.logicalOwner, b); + assertEq(recoveredA !== undefined, true); + assertEq(recoveredA?.cause, undefined); + assertEq(recoveredA?.owner, a); + assertEq(recoveredA?.logicalOwner, a); + assertEq(takeComponentBoundaryTrap(carrierA), undefined); +}); + +Deno.test("same-owner carriers remain independently attributable", () => { + const owner = { storage: [], task: {} }; + const causeA = new Trap("first"); + const causeB = new Trap("second"); + const carrierA = componentBoundaryTrapCarrier(causeA, owner); + const carrierB = componentBoundaryTrapCarrier(causeB, owner); + assertEq(takeComponentBoundaryTrap(carrierB)?.cause, causeB); + assertEq(takeComponentBoundaryTrap(carrierA)?.cause, causeA); +}); + +Deno.test("a suppressed carrier cannot contaminate a later unrelated RuntimeError", () => { + const owner = { storage: [], task: {} }; + componentBoundaryTrapCarrier(new Trap("suppressed"), owner); + let caught: unknown; + try { + withActivation(owner, () => callCore(unreachableCoreFn() as never, [])); + } catch (e) { + caught = e; + } + assertEq(caught instanceof Trap, true); + assertEq((caught as Trap).message, "guest trapped: unreachable"); +}); + +Deno.test("nested barriers transfer an undefined cause instead of replacing it", () => { + const owner = { storage: [], task: {} }; + const first = componentBoundaryTrapCarrier(undefined, owner); + const firstRecord = takeComponentBoundaryTrap(first); + assertEq(firstRecord !== undefined, true); + const second = componentBoundaryTrapCarrier(firstRecord!.cause, owner); + const recovered = takeComponentBoundaryTrap(second); + assertEq(recovered !== undefined, true); + assertEq(recovered?.cause, undefined); +}); + +Deno.test({ + name: "rejected Suspending import resumes as an uncatchable native trap", + ignore: typeof WASM_JSPI.promising !== "function" || + typeof WASM_JSPI.Suspending !== "function", + fn: async () => { + const owner = { storage: [], task: {} }; + const cause = new Trap("rejected import"); + const imported = suspendingImport(() => Promise.reject(cause), "jspi"); + const run = guestCatcher(imported as unknown as () => void); + let rejection: unknown; + try { + await withActivation(owner, () => WASM_JSPI.promising!(run)()); + } catch (e) { + rejection = e; + } + // A catchable rejection makes the guest return 1 instead. + const recovered = takeComponentBoundaryTrap(rejection); + assertEq(recovered?.cause, cause); + assertEq(recovered?.owner, owner); + }, +}); + +Deno.test({ + name: + "rejected sync lower crosses its real trampoline as an uncatchable trap", + ignore: typeof WASM_JSPI.promising !== "function" || + typeof WASM_JSPI.Suspending !== "function", + fn: async () => { + const inst = new ComponentInstanceState(0, new Store()); + const lowerOpts: ResolvedOptions = { + stringEncoding: "utf8", + memory: null, + realloc: null, + postReturn: null, + callback: null, + async: false, + cancellable: false, + coreType: { params: [], results: [] }, + instance: inst, + }; + const cause = new Error("host rejection"); + const lower = createLoweredImport({ + name: "rejecting", + ft: { params: [], results: [] }, + opts: lowerOpts, + hostFn: adaptHostFunction(() => Promise.reject(cause)), + stats: newStats(), + mode: "jspi", + suspendable: true, + deferCancel: false, + abortable: false, + }); + const trampoline = createTrampoline( + { kind: "lower-import", lowered: 0, options: 0, type: 0 } as never, + { + trapScope: {}, + loweredImport: () => lower, + } as unknown as TrampolineContext, + ); + const guest = guestCatcher( + suspendingImport(trampoline as never, "jspi") as unknown as () => void, + ); + const lifted = createLiftedFunction({ + name: "run", + ft: { params: [], results: [{ kind: "u32" }] }, + opts: { + ...lowerOpts, + coreType: { params: [], results: ["i32"] }, + }, + core: guest as never, + stats: newStats(), + suspensionMode: "jspi", + }); + + let rejection: unknown; + try { + await lifted(); + } catch (e) { + rejection = e; + } + // If the rejected host Promise crossed as a catchable JS exception, the + // guest returns 1 and this call fulfills instead. + assertEq(rejection, cause); + }, +}); + +Deno.test({ + name: + "abandoned sync lower crosses its real trampoline as an uncatchable trap", + ignore: typeof WASM_JSPI.promising !== "function" || + typeof WASM_JSPI.Suspending !== "function", + fn: async () => { + const store = new Store(); + const inst = new ComponentInstanceState(0, store); + const lowerOpts: ResolvedOptions = { + stringEncoding: "utf8", + memory: null, + realloc: null, + postReturn: null, + callback: null, + async: false, + cancellable: false, + coreType: { params: [], results: [] }, + instance: inst, + }; + const cause = new Error("abandoned import"); + const lower = createLoweredImport({ + name: "abandoned", + ft: { params: [], results: [] }, + opts: lowerOpts, + hostFn: adaptHostFunction(() => new Promise(() => {})), + stats: newStats(), + mode: "jspi", + suspendable: true, + deferCancel: false, + abortable: false, + }); + const trampoline = createTrampoline( + { kind: "lower-import", lowered: 0, options: 0, type: 0 } as never, + { + trapScope: {}, + loweredImport: () => lower, + } as unknown as TrampolineContext, + ); + const guest = guestCatcher( + suspendingImport(trampoline as never, "jspi") as unknown as () => void, + ); + const lifted = createLiftedFunction({ + name: "run", + ft: { params: [], results: [{ kind: "u32" }] }, + opts: { + ...lowerOpts, + coreType: { params: [], results: ["i32"] }, + }, + core: guest as never, + stats: newStats(), + suspensionMode: "jspi", + }); + + const result = lifted() as Promise; + await Promise.resolve(); + const point = store.waiting[0] as unknown as { + abandon(reason: unknown): void; + }; + point.abandon(cause); + let rejection: unknown; + try { + await result; + } catch (e) { + rejection = e; + } + assertEq(rejection, cause); + }, +}); diff --git a/runtime/tests/deferred_test.ts b/runtime/tests/deferred_test.ts index e8b962c8..5ddcbb96 100644 --- a/runtime/tests/deferred_test.ts +++ b/runtime/tests/deferred_test.ts @@ -18,13 +18,6 @@ const deferred: [name: string, reason: string][] = [ "no wire form for instance nesting — v0.3 contract friction, not a " + "scheduler gap", ], - [ - "threads: test_threads, test_sync_threads (thread.* built-ins)", - "🧵 shared-everything threads (thread.new-indirect, " + - "thread.{suspend,resume-later,switch-to,...}) are deferred with memory64 " + - "per https://github.com/polymorph-components/polyengine/issues/12; context.get/set — the part of this group that async " + - "guests actually use — IS implemented (intrinsics/context.ts)", - ], ]; for (const [name, reason] of deferred) { diff --git a/runtime/tests/dtor_guest_context_test.ts b/runtime/tests/dtor_guest_context_test.ts index efbc0809..043290d2 100644 --- a/runtime/tests/dtor_guest_context_test.ts +++ b/runtime/tests/dtor_guest_context_test.ts @@ -103,14 +103,11 @@ for (const capability of [false, true]) { const failure = capability ? new NeedsJspi("dtor probe") : new Trap("dtor trap"); - const trapState = { pending: failure as unknown }; const entry = createDtorEntry({ instance: impl, guestCaller: caller, - trapState, allInstances: () => [caller, impl], dtor: () => { - assertEq(trapState.pending === failure, true); assertEq(currentTask().inst === impl, true); throw failure; }, @@ -132,7 +129,6 @@ for (const capability of [false, true]) { assertEq(caught === failure, true); assertEq(currentThread() === outer, true); assertEq(caller.mayLeave, false); - assertEq(trapState.pending === failure, true); caller.mayLeave = true; }, }); diff --git a/runtime/tests/enter_sync_call_reentrance_test.ts b/runtime/tests/enter_sync_call_reentrance_test.ts index 8a03a665..cac3b071 100644 --- a/runtime/tests/enter_sync_call_reentrance_test.ts +++ b/runtime/tests/enter_sync_call_reentrance_test.ts @@ -62,7 +62,6 @@ function fixture() { syncCallStack, factStartScopes: [], stats: newStats(), - trapState: { pending: undefined }, } as unknown as TrampolineContext; const enter = createTrampoline( { kind: "enter-sync-call", index: 0 } as never, diff --git a/runtime/tests/event_driven_drain_test.ts b/runtime/tests/event_driven_drain_test.ts index a76b6b2a..49dd656c 100644 --- a/runtime/tests/event_driven_drain_test.ts +++ b/runtime/tests/event_driven_drain_test.ts @@ -254,6 +254,67 @@ Deno.test({ }, }); +Deno.test({ + name: + "pending same-instance entry is offered before a perpetual callback yield", + ignore: !isSupported(), + fn: async () => { + const wasm = await instantiateActivation({ + block: new WebAssembly.Suspending((x: number) => x), + }); + const store = new Store(); + const inst = new ComponentInstanceState(0, store); + const hop = { + awaiting: new Promise(() => {}), + activePark: null, + task: { inst }, + }; + store.awaiting.add(hop); + + const run = createLiftedFunction({ + name: "admit-at-boundary", + ft: { params: [{ kind: "u32" }], results: [{ kind: "u32" }] }, + opts: { + stringEncoding: "utf8", + memory: null, + realloc: null, + postReturn: null, + callback: null, + async: false, + cancellable: false, + coreType: { params: ["i32"], results: ["i32"] }, + instance: inst, + }, + core: wasm.other, + stats: newStats(), + suspensionMode: "jspi", + }); + + const result = run(42) as Promise; + // The old observer waited for a whole drain batch. A ready thread that + // perpetually requeues itself could therefore starve this entry forever. + const yielder = new YieldingThread(); + store.startWaiting(yielder); + const park = { + owner: hop, + task: hop.task, + ready: () => false, + waiting: () => true, + resume() {}, + }; + store.startWaiting(park); + + assertEquals(await result, 1042); + assert( + yielder.resumed <= 1, + `pending admission was starved for ${yielder.resumed} ordinary turns`, + ); + store.stopWaiting(yielder); + store.stopWaiting(park); + store.awaiting.delete(hop); + }, +}); + Deno.test("ordinary service stops when the first tail creates an unqueued hop", async () => { const store = new Store(); let secondRan = false; diff --git a/runtime/tests/fixtures/thread-switch-matrix.wasm b/runtime/tests/fixtures/thread-switch-matrix.wasm new file mode 100644 index 0000000000000000000000000000000000000000..9af1ce57b5c69410336be6d81de21c3526d1b9f9 GIT binary patch literal 3086 zcmdT`&2r;J5bhrR*w)Cl>?Drk-ObM~EJ-N60aUGO54->m5XBxguGo@GR!DO4iRa+V zi@+6b6no^K0*WV~==qT|N}Sp=PVAZP`MRg4r+)_0<-83DjSyd<_jx+I61NCJ3&EYI z#U(9x_?p&(E`7%r)7d0`la>Op;ztPlBA-n}p%)k)1L;Hna9vzL*l3|wrsp$3thSq_ za{<2hsbnx$qI`~k2pZ#fc!n!z9)@y>7U++`%MSpKPw*5@@k5BA1-F3z@M;^}_od7k z&_Axu*lI<;D1V%loU=c$ZPZJ9^ub8GbsXBoJ_M0rc~OXT5>K;~rWnRUPq<+(#q2Y9+Bej7k!Nq>Wl4U5XU5@WkuG3A)WMYH z#XOzCK~IxfO#APU&+_qQtb+@V_#$7H%89cQo_DS?x}Wid^mIHvhZn6rGDv_!5Vkwr zsMp`x-U+^Ph5;l&1cwlLybH;;nl?tsAOcD6E4npG4vF?5Io88RGz=iZ1Ag&UZ=1wBj%T0n&a-t8XiBnqr4fsoGjH^+JuK-PHPJ*AfuYe~X6YO{3 z9OPoMikc%cj~qGQQyZ-}@hTM~9+HIQM;>{ah=xrN&#aKrAEe`0@)x;`p!-r|ORkTS zrX0vgf6@|JEj3A>!k4Zt4xaluF9iO}2BrjAdCKf8b!s{4S{drq!rWh{p%q20ozQBNru)jzPk(KTBth1gCShb3t(y@D=F}%;% zqdKpkBW5PpGFFSpvug1M&rK(e+xoKC~*tP$}EcI}~sWg37QLOqy-QxFqkjgeJXVV4L}k9bSpOuf%HU@R;C$ z1b4$q@KgzI=uwYsJ0$77b|w9ulCE{LN90B%_oQ=A&hC4U$UP;wXI=giJ#=ajgR-(( zMFw5Un7ayt9zb>X=u>RjH;oN)pJCK`JvK?Hb&J>n*l=eUtg};geB@HUdO+%)k0HQA zkJ1Ui_7rxAwmg7cfbUsnoX;0|MujPs4@!JXR~q<>O=4b`Ucie5)4!W>dLDN1U8rZ$nS=OA36fyUTz$5 z!J_1Bq6Ji1^94y$jx6EKP9<-DZv&zGPaFi+5)+uG`B5xRDETPh{v}*>^j%;f$m%Bg h_FPB0_>5aoc@_HR8>$KDgCJP|vu&cx%8gkM`~Yq*C2{}& literal 0 HcmV?d00001 diff --git a/runtime/tests/fixtures/thread-type-equivalence.wat b/runtime/tests/fixtures/thread-type-equivalence.wat new file mode 100644 index 00000000..6bde535d --- /dev/null +++ b/runtime/tests/fixtures/thread-type-equivalence.wat @@ -0,0 +1,13 @@ +(module + (type $final (sub final (func (param i32)))) + (type $nonfinal (sub (func (param i32)))) + (type $derived (sub $nonfinal (func (param i32)))) + (table (export "table") 3 funcref) + (global $runs (export "runs") (mut i32) (i32.const 0)) + (func $final-fn (type $final) (param i32) + (global.set $runs (i32.add (global.get $runs) (i32.const 1)))) + (func $nonfinal-fn (type $nonfinal) (param i32) + (global.set $runs (i32.add (global.get $runs) (i32.const 1)))) + (func $derived-fn (type $derived) (param i32) + (global.set $runs (i32.add (global.get $runs) (i32.const 1)))) + (elem (i32.const 0) $final-fn $nonfinal-fn $derived-fn)) diff --git a/runtime/tests/jspi/deadlock_test.ts b/runtime/tests/jspi/deadlock_test.ts index c1c131db..35bc01c1 100644 --- a/runtime/tests/jspi/deadlock_test.ts +++ b/runtime/tests/jspi/deadlock_test.ts @@ -75,8 +75,8 @@ Deno.test({ // Tightened from the earlier "trap OR capability signal" form, which was // only ever a placeholder for this. assert( - message!.includes("deadlock"), - `expected a deadlock trap, got: ${message}`, + message!.includes("cannot block a synchronous task before returning"), + `expected a synchronous-block trap, got: ${message}`, ); }, }); diff --git a/runtime/tests/jspi/thread_switch_matrix_test.ts b/runtime/tests/jspi/thread_switch_matrix_test.ts new file mode 100644 index 00000000..7ff2a99c --- /dev/null +++ b/runtime/tests/jspi/thread_switch_matrix_test.ts @@ -0,0 +1,286 @@ +import { instantiate } from "../../src/embedder/mod.ts"; +import type { EmbedderInstance } from "../../src/embedder/instantiate.ts"; +import { Translator } from "../../src/shim/mod.ts"; +import { isTrap, suspending } from "@polyengine/protocol"; +import { assert, assertEquals, assertRejects } from "./asserts.ts"; + +const root = new URL("../../../", import.meta.url); +const shim = await Deno.readFile( + new URL("target/wasm32-unknown-unknown/release/translator_shim.wasm", root), +); +const fixture = await Deno.readFile( + new URL("../fixtures/thread-switch-matrix.wasm", import.meta.url), +); +const translator = await Translator.create(shim); + +type Component = EmbedderInstance & { order: number[] }; +type GateOptions = { + childGate?: () => Promise; + holderGate?: () => Promise; +}; + +async function bounded(promise: Promise, label: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`timeout: ${label}`)), 1000); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +async function fresh( + onMark?: (value: number, component: Component) => void, + gates: GateOptions = {}, +) { + const order: number[] = []; + const holder: { current: Component | null } = { current: null }; + const instance = await instantiate( + { componentBytes: fixture, ...translator.translate(fixture) }, + { + mark(value: number) { + order.push(value); + assert(holder.current !== null, "component must be bound before mark"); + onMark?.(value, holder.current); + }, + childGate: suspending(gates.childGate ?? (() => Promise.resolve())), + holderGate: suspending(gates.holderGate ?? (() => Promise.resolve())), + }, + { jspi: true }, + ); + const component = Object.assign(instance, { order }); + holder.current = component; + return component; +} + +async function run( + name: string, + expectedValue: number, + expectedOrder: number[], +): Promise { + const component = await fresh(); + const value = await bounded( + component.exports[name]() as Promise, + name, + ); + assertEquals(value, expectedValue); + assertEquals(JSON.stringify(component.order), JSON.stringify(expectedOrder)); +} + +Deno.test("real guest: suspend-then-resume target immediately resume-laters caller", async () => { + await run("resumeLater", 101, [1, 10, 11]); +}); + +Deno.test("real guest: suspend-then-resume target immediately switches back", async () => { + const component = await fresh(); + assertEquals( + await bounded( + component.exports.switchBack() as Promise, + "switch-back", + ), + 102, + ); + assertEquals(JSON.stringify(component.order), JSON.stringify([2, 20, 21])); + assert( + component.handle.componentInstances.some((inst) => + inst !== undefined && + [...inst.threads].some((thread) => thread.explicitlySuspended()) + ), + "switch-back must leave the target child suspended", + ); +}); + +Deno.test("real guest: yield-then-resume promotes the ready caller", async () => { + await run("yieldBack", 103, [3, 30, 31, 32]); +}); + +Deno.test("real guest: promote ignores suspended target and runs ready target first", async () => { + await run("promote", 104, [4, 41, 40, 42]); +}); + +function requestCurrentTaskCancellation(component: Component): void { + const tasks = new Set( + component.handle.componentInstances + .filter((inst) => inst !== undefined) + .flatMap((inst) => [...inst.threads].map((thread) => thread.task)), + ); + assertEquals(tasks.size, 1, "mark must run with exactly one live task"); + [...tasks][0].requestCancellation(null); +} + +Deno.test("real guest: valid pending cancellation cancels without transfer", async () => { + const component = await fresh((value, current) => { + if (value === 6) requestCurrentTaskCancellation(current); + }); + assertEquals( + await bounded( + component.exports.cancelValid() as Promise, + "cancel-valid", + ), + 105, + ); + assertEquals(JSON.stringify(component.order), JSON.stringify([6, 61])); +}); + +for (const name of ["cancelInvalidIndex", "cancelSelf", "cancelWrongState"]) { + Deno.test(`real guest: ${name} validates before pending cancellation`, async () => { + let requestedTask: { state: string } | null = null; + const component = await fresh((value, current) => { + if (value !== 6) return; + const tasks = current.handle.componentInstances + .filter((inst) => inst !== undefined) + .flatMap((inst) => [...inst.threads].map((thread) => thread.task)); + assert(tasks.length > 0); + requestedTask = tasks[0]; + tasks[0].requestCancellation(null); + }); + const failure = await assertRejects(() => + bounded(component.exports[name]() as Promise, name) + ); + assert(isTrap(failure), `expected validation trap, got ${failure}`); + assert(requestedTask !== null); + assertEquals((requestedTask as { state: string }).state, "pending-cancel"); + }); +} + +Deno.test("real guest: explicit child trap remains the original cause", async () => { + const component = await fresh(); + const failure = await assertRejects(() => + bounded(component.exports.childTrap() as Promise, "child-trap") + ); + assert( + isTrap(failure) && String(failure).includes("unreachable"), + `expected original unreachable trap, got ${failure}`, + ); +}); + +Deno.test("real guest: normal last explicit child reports no async result", async () => { + const component = await fresh(); + const failure = await assertRejects(() => + bounded(component.exports.childNormal() as Promise, "child-normal") + ); + assert( + isTrap(failure) && String(failure).includes("without resolving"), + `expected last-thread no-result trap, got ${failure}`, + ); +}); + +for (const timing of ["pending", "immediate"] as const) { + Deno.test(`real guest: explicit child receives ${timing} cancellation while sibling owns exclusive slot`, async () => { + const childGate = Promise.withResolvers(); + const childEntered = Promise.withResolvers(); + const holderGate = Promise.withResolvers(); + const holderEntered = Promise.withResolvers(); + const childAtPark = Promise.withResolvers(); + const component = await fresh( + (value) => { + if (value === 70) childAtPark.resolve(); + }, + { + childGate: () => { + childEntered.resolve(); + return childGate.promise; + }, + holderGate: () => { + holderEntered.resolve(); + return holderGate.promise; + }, + }, + ); + + const originResult = component.exports.childCancellable() as Promise< + number + >; + await bounded(childEntered.promise, "child initial noncancellable park"); + const instance = component.handle.componentInstances.find((inst) => + inst !== undefined && [...inst.threads].length > 0 + )!; + const origin = [...instance.threads][0].task; + assertEquals( + origin.implicitThread?.index, + null, + "origin implicit thread exited", + ); + + const holderResult = component.exports.lockHolder() as Promise; + try { + await bounded(holderEntered.promise, "exclusive holder park"); + const exclusive = instance.exclusiveThread; + assert(exclusive !== null, "lock-holder must own the exclusive slot"); + assert( + exclusive.task !== origin, + "exclusive holder must be a sibling task", + ); + + if (timing === "pending") origin.requestCancellation(null); + childGate.resolve(); + await bounded(childAtPark.promise, "child cancellable park"); + if (timing === "immediate") { + await bounded( + (async () => { + while ( + !(instance.store as unknown as { + waiting: { task?: unknown; cancellable: boolean }[]; + }).waiting.some((waiter) => + waiter.task === origin && waiter.cancellable + ) + ) { + await Promise.resolve(); + } + })(), + "registered child cancellable park", + ); + origin.requestCancellation(null); + } + + assertEquals( + await bounded(originResult, `child-cancellable-${timing}`), + 110, + ); + assertEquals( + JSON.stringify(component.order), + JSON.stringify([90, 70, 71]), + ); + assertEquals( + instance.exclusiveThread, + exclusive, + "holder keeps the slot", + ); + } finally { + childGate.resolve(); + holderGate.resolve(); + } + assertEquals(await bounded(holderResult, "holder cleanup"), 109); + }); +} + +Deno.test("real guest: post-result ready child continues and suspended child is retained", async () => { + const component = await fresh(); + assertEquals( + await bounded( + component.exports.postResult() as Promise, + "post-result", + ), + 108, + ); + await bounded( + (async () => { + while (!component.order.includes(80)) { + await new Promise((r) => setTimeout(r, 0)); + } + })(), + "post-result child continuation", + ); + assertEquals(JSON.stringify(component.order), JSON.stringify([80])); + assert( + component.handle.componentInstances.some((inst) => + inst !== undefined && + [...inst.threads].some((thread) => thread.explicitlySuspended()) + ), + "post-result suspended child must remain registered", + ); +}); diff --git a/runtime/tests/resource_identity_test.ts b/runtime/tests/resource_identity_test.ts index 47a99b9a..81541eea 100644 --- a/runtime/tests/resource_identity_test.ts +++ b/runtime/tests/resource_identity_test.ts @@ -244,7 +244,6 @@ Deno.test("FACT resource transfer validates local source and tags destination", resourceTableInstance: (i: number) => i < 2 ? f.src : f.dst, syncCallStack: scopes, factStartScopes: [], - trapState: { pending: null }, } as unknown as TrampolineContext; for ( const kind of ["resource-transfer-own", "resource-transfer-borrow"] as const diff --git a/runtime/tests/subtask_cancel_sync_waiter_window_test.ts b/runtime/tests/subtask_cancel_sync_waiter_window_test.ts index fd664e18..b073dfdd 100644 --- a/runtime/tests/subtask_cancel_sync_waiter_window_test.ts +++ b/runtime/tests/subtask_cancel_sync_waiter_window_test.ts @@ -338,13 +338,13 @@ Deno.test( Deno.test( "#345 FACT/JSPI: unresolved async cancel returns BLOCKED, then join/poll resolves", - () => { + async () => { const f = startFactSubtask(); f.st.getPendingEvent(); const lender = { numLends: 0 }; f.st.addLender(lender); assertEq( - f.asGuest(() => + await f.asGuest(() => createSubtaskCancel({ async: true }, f.caller, "jspi")(f.i) ), BLOCKED, diff --git a/runtime/tests/sync_callee_progress_test.ts b/runtime/tests/sync_callee_progress_test.ts new file mode 100644 index 00000000..24061290 --- /dev/null +++ b/runtime/tests/sync_callee_progress_test.ts @@ -0,0 +1,168 @@ +import { Trap } from "../src/cabi/trap.ts"; +import { requestStoreService } from "../src/exec/boundary.ts"; +import { blockCurrentActivation } from "../src/jspi/bridge.ts"; +import { + type BlockRequest, + ComponentInstanceState, + markHostActivityArm, + popLogicalActivation, + SynchronousActivation, + takeComponentBoundaryTrap, + Task, + type TaskOptions, + Thread, + withActivation, +} from "../src/task/mod.ts"; +import { assertEq } from "./support/asserts.ts"; + +function assert(condition: unknown): asserts condition { + if (!condition) throw new Error("assertion failed"); +} + +const SYNC_OPTS: TaskOptions = { + async_: false, + callback: false, + stringEncoding: "utf8", + memory: null, +}; + +function root(inst: ComponentInstanceState): { task: Task; thread: Thread } { + const task = new Task( + { params: [], results: [], async: true }, + { ...SYNC_OPTS, async_: true }, + inst, + () => [], + () => {}, + ); + const thread = new Thread(task, (function* (): Generator {})()); + task.implicitThread = thread; + task.attachCall(); + return { task, thread }; +} + +async function parkedSyncChild(input: { + retain?: Promise; + retentionOnly?: boolean; + asyncTyped?: boolean; + blockReason?: "component-model" | "host-import"; + producer?: boolean; +} = {}): Promise< + { result: Promise; parent: Thread; resume: () => void } +> { + const store = new ComponentInstanceState(99).store; + const parentInst = new ComponentInstanceState(0, store); + const inst = new ComponentInstanceState(1, store); + const parent = root(parentInst).thread; + const child = new SynchronousActivation( + inst, + input.asyncTyped === true, + parent, + ); + popLogicalActivation(child.thread); + const result = withActivation(child.thread, () => + blockCurrentActivation({ + store: inst.store, + task: child.task, + readyFunc: () => false, + cancellable: false, + produce: () => 7, + blockReason: input.blockReason, + })) as Promise; + const point = inst.store.waiting[0]; + const resume = () => point.resume(); + if (input.retain !== undefined) { + if (input.retentionOnly) markHostActivityArm(input.retain); + inst.store.pendingHostCalls.add(input.retain); + } + if (input.producer) { + const producerTask = new Task( + { params: [], results: [], async: false }, + SYNC_OPTS, + inst, + () => [], + () => {}, + ); + const producer = new Thread( + producerTask, + // deno-lint-ignore require-yield + (function* () { + resume(); + })(), + ); + producerTask.registerThread(producer); + producer.resumeLater(); + } + await Promise.resolve(); // publish boundaryReturned / required-sync identity + requestStoreService(inst.store); + return { result, parent, resume }; +} + +async function semanticRejection( + result: Promise, +): Promise { + try { + await result; + throw new Error("expected rejection"); + } catch (carrier) { + return takeComponentBoundaryTrap(carrier)?.cause ?? carrier; + } +} + +Deno.test("sync logical callee rejects at a genuine CM park", async () => { + const { result } = await parkedSyncChild(); + const cause = await semanticRejection(result); + assert( + cause instanceof Trap && + cause.message.includes( + "cannot block a synchronous task before returning", + ), + ); +}); + +Deno.test( + "unrelated host retention or real import cannot suppress a sync-callee trap", + async () => { + for (const retentionOnly of [true, false]) { + const retained = new Promise(() => {}); + const { result } = await parkedSyncChild({ + retain: retained, + retentionOnly, + }); + const cause = await semanticRejection(result); + assert( + cause instanceof Trap && + cause.message.includes( + "cannot block a synchronous task before returning", + ), + ); + } + }, +); + +Deno.test("same-instance runnable producer may satisfy a sync callee", async () => { + const { result } = await parkedSyncChild({ + // A ready thread of the callee instance is exactly the candidate set in + // definitions.py:2191-2193. It resolves the parked operation in its turn. + producer: true, + }); + assertEq(await result, 7); +}); + +Deno.test("async-typed callee and host-import latency are allowed to remain pending", async () => { + for ( + const options of [{ asyncTyped: true }, { + blockReason: "host-import" as const, + }] + ) { + const { result, resume } = await parkedSyncChild(options); + const verdict = await Promise.race([ + result.then(() => "settled"), + new Promise((resolve) => + setTimeout(() => resolve("pending"), 10) + ), + ]); + assertEq(verdict, "pending"); + resume(); + await result; + } +}); diff --git a/runtime/tests/thread_builtins_test.ts b/runtime/tests/thread_builtins_test.ts new file mode 100644 index 00000000..69dbf5b6 --- /dev/null +++ b/runtime/tests/thread_builtins_test.ts @@ -0,0 +1,321 @@ +import { assertEq } from "./support/asserts.ts"; +import type { FuncType } from "../src/cabi/types.ts"; +import { + ComponentInstanceState, + popCurrentThread, + pushCurrentThread, + Store, + Task, + Thread, + withSynchronousActivation, +} from "../src/task/mod.ts"; +import { + createThreadIndex, + createThreadNewIndirect, + createThreadResumeLater, + createThreadSuspendThenResume, + createThreadYieldThenResume, + type ThreadTrampolineContext, +} from "../src/intrinsics/thread_builtins.ts"; +import { createTaskReturn } from "../src/intrinsics/async_builtins.ts"; + +const FT: FuncType = { params: [], results: [], async: true }; + +function fixture(table: WebAssembly.Table) { + const inst = new ComponentInstanceState(0, new Store()); + const task = new Task( + FT, + { + async_: true, + callback: false, + stringEncoding: "utf8", + memory: null, + }, + inst, + () => [], + () => {}, + ); + task.state = "started"; + const parent = new Thread(task, (function* () {})()); + task.implicitThread = parent; + task.registerThread(parent); + const ctx: ThreadTrampolineContext = { + componentInstance: () => inst, + runtimeTable: () => table, + suspensionMode: "plain", + enterThreadFunction: (fn) => fn, + }; + return { inst, task, parent, ctx }; +} + +function moduleFromWatBytes(bytes: number[]): WebAssembly.Instance { + return new WebAssembly.Instance( + new WebAssembly.Module(new Uint8Array(bytes)), + ); +} + +// (module (table (export "t") 3 funcref) +// (func $ok (param i32)) (func $wrong (param i64) (result i32) i32.const 0) +// (elem (i32.const 0) $ok $wrong)) +const TABLE_FIXTURE = [ + 0, + 97, + 115, + 109, + 1, + 0, + 0, + 0, + 1, + 14, + 3, + 96, + 1, + 127, + 0, + 96, + 1, + 126, + 0, + 96, + 1, + 126, + 1, + 127, + 3, + 4, + 3, + 0, + 1, + 2, + 4, + 4, + 1, + 112, + 0, + 4, + 7, + 5, + 1, + 1, + 116, + 1, + 0, + 9, + 9, + 1, + 0, + 65, + 0, + 11, + 3, + 0, + 1, + 2, + 10, + 12, + 3, + 2, + 0, + 11, + 2, + 0, + 11, + 4, + 0, + 65, + 0, + 11, +]; + +Deno.test("thread.new-indirect validates before registration and starts only after resume-later", () => { + const module = moduleFromWatBytes(TABLE_FIXTURE); + const table = module.exports.t as WebAssembly.Table; + const f = fixture(table); + const create = createThreadNewIndirect( + { instance: 0, startFuncTable: 0 }, + f.ctx, + ); + const resume = createThreadResumeLater({ instance: 0 }, f.ctx); + + pushCurrentThread(f.parent); + try { + const beforeThreads = [...f.inst.threads].length; + for (const [index, closure] of [[1, 7], [2, 7n], [3, 7]] as const) { + try { + create(index, closure); + throw new Error(`index ${index} unexpectedly accepted`); + } catch { + assertEq( + [...f.inst.threads].length, + beforeThreads, + "failed new has no effects", + ); + } + } + const childIndex = create(0, 7) as number; + assertEq([...f.inst.threads].length, 2, "child registered suspended"); + resume(childIndex); + assertEq(f.inst.store.tick(), true, "child scheduled"); + assertEq([...f.inst.threads].length, 1, "child unregistered on return"); + const child64 = create(1, 7n) as number; + resume(child64); + assertEq(f.inst.store.tick(), true, "i64 child scheduled"); + assertEq(createThreadIndex({ instance: 0 }, f.ctx)(), f.parent.index); + } finally { + popCurrentThread(f.parent); + } +}); + +Deno.test("SUPPORTED LIMITATION: equivalent non-final thread start types are rejected without execution", async () => { + // CONTRACT: Core function-type equality accepts equivalent non-final and + // derived types (definitions.py:2673-2674). WebAssembly's JS API exposes no + // signature reflection, while ref.test against our final canonical type + // rejects these valid inputs. Translator metadata is the required fix; this + // test records an implementation rejection, not spec-invalid guest input. + const bytes = await Deno.readFile( + new URL("./fixtures/thread-type-equivalence.wasm", import.meta.url), + ); + const module = new WebAssembly.Instance(new WebAssembly.Module(bytes)); + const table = module.exports.table as WebAssembly.Table; + const runs = module.exports.runs as WebAssembly.Global; + const f = fixture(table); + const create = createThreadNewIndirect( + { instance: 0, startFuncTable: 0 }, + f.ctx, + ); + + pushCurrentThread(f.parent); + try { + const finalThread = create(0, 7) as number; + assertEq([...f.inst.threads].length, 2, "final control is accepted"); + for (const index of [1, 2]) { + let rejected = false; + try { + create(index, 7); + } catch { + rejected = true; + } + assertEq(rejected, true, `equivalent type ${index} hits limitation`); + assertEq(runs.value, 0, "validation must not execute any target"); + assertEq( + [...f.inst.threads].length, + 2, + "rejection has no registration effect", + ); + } + const resume = createThreadResumeLater({ instance: 0 }, f.ctx); + resume(finalThread); + f.inst.store.tick(); + assertEq( + runs.value, + 1, + "accepted final control executes only when resumed", + ); + } finally { + popCurrentThread(f.parent); + } +}); + +Deno.test("thread switch validates operands before delivering pending cancellation", () => { + const module = moduleFromWatBytes(TABLE_FIXTURE); + const f = fixture(module.exports.t as WebAssembly.Table); + const create = createThreadNewIndirect( + { instance: 0, startFuncTable: 0 }, + f.ctx, + ); + const switchedCtx = { ...f.ctx, suspensionMode: "jspi" as const }; + const suspendThenResume = createThreadSuspendThenResume( + { instance: 0, cancellable: true }, + switchedCtx, + ); + const yieldThenResume = createThreadYieldThenResume( + { instance: 0, cancellable: true }, + switchedCtx, + ); + + pushCurrentThread(f.parent); + try { + f.task.state = "pending-cancel"; + for ( + const [fn, target] of [ + [suspendThenResume, 999], + [yieldThenResume, f.parent.index!], + ] as const + ) { + let trapped = false; + try { + fn(target); + } catch { + trapped = true; + } + assertEq(trapped, true, "invalid target traps before cancellation"); + assertEq(f.task.state, "pending-cancel", "pending cancel not consumed"); + } + + const child = create(0, 7) as number; + f.task.state = "pending-cancel"; + assertEq(suspendThenResume(child), 1, "valid target receives cancellation"); + assertEq(f.task.state, "cancel-delivered"); + assertEq( + f.inst.threads.get(child).explicitlySuspended(), + true, + "target not switched", + ); + } finally { + popCurrentThread(f.parent); + } +}); + +Deno.test("exceptional explicit-thread cleanup does not synthesize resolution", () => { + const module = moduleFromWatBytes(TABLE_FIXTURE); + const f = fixture(module.exports.t as WebAssembly.Table); + const child = new Thread(f.task, (function* () {})()); + f.task.registerThread(child); + f.task.abortThread(child); + assertEq(f.task.state, "started", "cleanup did not resolve the task"); + assertEq([...f.inst.threads].length, 1, "only the parent remains"); +}); + +Deno.test("same-instance synchronous activation keeps task.return on the logical task", () => { + const module = moduleFromWatBytes(TABLE_FIXTURE); + const f = fixture(module.exports.t as WebAssembly.Table); + let outerResolved = false; + f.task.onResolve = () => { + outerResolved = true; + }; + const taskReturn = createTaskReturn( + { results: 0, resultType: null, options: 0 }, + { + componentInstance: () => f.inst, + options: () => ({ + async: true, + callback: null, + memory: null, + realloc: null, + postReturn: null, + stringEncoding: "utf8", + cancellable: false, + instance: f.inst, + coreType: { params: [], results: [] }, + }), + resultTypes: () => [], + } as never, + f.inst, + ); + + pushCurrentThread(f.parent); + try { + let trapped = false; + try { + withSynchronousActivation(f.inst, () => taskReturn()); + } catch { + trapped = true; + } + assertEq(trapped, true, "sync logical task.return traps"); + assertEq(outerResolved, false, "outer task was not resolved"); + assertEq(f.task.state, "started"); + } finally { + popCurrentThread(f.parent); + } +}); diff --git a/runtime/tests/tls_smoke_pins_test.ts b/runtime/tests/tls_smoke_pins_test.ts index be8739dd..2156fae7 100644 --- a/runtime/tests/tls_smoke_pins_test.ts +++ b/runtime/tests/tls_smoke_pins_test.ts @@ -185,7 +185,6 @@ Deno.test("pin: transfer-borrow works inside a FACT [async-start] window", () => resourceToken: (i: number) => (i === 0 ? srcRt : dstRt), syncCallStack: [] as SyncCallScope[], factStartScopes, - trapState: { pending: null }, } as unknown as TrampolineContext; const transfer = createTrampoline( diff --git a/runtime/tests/wasmtime/cancel_starting_reuse_test.ts b/runtime/tests/wasmtime/cancel_starting_reuse_test.ts new file mode 100644 index 00000000..ffc4b9dc --- /dev/null +++ b/runtime/tests/wasmtime/cancel_starting_reuse_test.ts @@ -0,0 +1,96 @@ +// Semantic adaptation of Wasmtime's +// async/cancel-starting-subtask-does-not-leak.wast. That WAST lowers +// Wasmtime's private concurrent-resource-table capacity to 100, then performs +// 1,000 STARTING → cancel → delivery → drop cycles. Polyengine has no such +// production tuning knob, so exercise the same lifecycle and assert bounded +// reuse directly rather than installing a vacuous no-op provider. + +import { assertEq } from "../support/asserts.ts"; +import { + type BlockRequest, + type Cancelled, + ComponentInstanceState, + Store, + Subtask, + SubtaskState, + Task, + type TaskOptions, + Thread, +} from "../../src/task/mod.ts"; +import type { FuncType } from "../../src/cabi/types.ts"; +import { + createSubtaskCancel, + createSubtaskDrop, +} from "../../src/intrinsics/async_builtins.ts"; + +function parkedEntryThread(task: Task): Thread { + const holder: { thread?: Thread } = {}; + const body = (function* (): Generator { + if (!(yield* task.enterImplicitThread(holder.thread!))) return; + throw new Error("backpressured STARTING task unexpectedly entered"); + })(); + const thread = new Thread(task, body); + holder.thread = thread; + return thread; +} + +Deno.test("Wasmtime adaptation: cancelled STARTING subtasks reuse a bounded handle slot", () => { + const inst = new ComponentInstanceState(0, new Store()); + // Hold every callee at Task.enterImplicitThread's backpressure gate. This is + // the real STARTING state exercised by the upstream WAST, not a manually + // assigned Subtask enum value. + inst.backpressure = 1; + const cancel = createSubtaskCancel({ async: true }, inst); + const drop = createSubtaskDrop(inst); + let largestBackingTable = inst.handles.array.length; + + for (let iteration = 0; iteration < 1_000; iteration++) { + const subtask = new Subtask(); + const lender = { numLends: 0 }; + subtask.addLender(lender); + const ft: FuncType = { params: [], results: [], async: true }; + const opts: TaskOptions = { + async_: true, + callback: false, + stringEncoding: "utf8", + memory: null, + }; + const task = new Task(ft, opts, inst, () => [], (result) => { + subtask.resolve( + result === null + ? SubtaskState.CANCELLED_BEFORE_STARTED + : SubtaskState.RETURNED, + [], + ); + }); + const thread = parkedEntryThread(task); + thread.resume(); + subtask.onCancel = () => task.requestCancellation(null); + subtask.calleeTask = task; + const handle = inst.handles.add(subtask); + subtask.setSubtaskPendingEvent(handle); + + assertEq(subtask.state, SubtaskState.STARTING, "precondition"); + assertEq( + cancel(handle), + SubtaskState.CANCELLED_BEFORE_STARTED, + "cancel status", + ); + assertEq(subtask.resolveDelivered(), true, "resolution delivered"); + assertEq(lender.numLends, 0, "lender released"); + assertEq(thread.done(), true, "cancelled entry thread retired"); + drop(handle); + + assertEq([...inst.handles].length, 0, "no live subtask handles"); + assertEq([...inst.threads].length, 0, "subtask cycle created no threads"); + largestBackingTable = Math.max( + largestBackingTable, + inst.handles.array.length, + ); + } + + // Slot zero is reserved and one slot is repeatedly allocated. Growth here + // would reproduce the leak which the native capacity control detects. + assertEq(largestBackingTable, 2, "handle backing table stayed bounded"); + assertEq(inst.handles.free.length, 1, "one reusable slot remains"); +}); diff --git a/tools/shell/entry.ts b/tools/shell/entry.ts index dec5489c..b4b6fffe 100644 --- a/tools/shell/entry.ts +++ b/tools/shell/entry.ts @@ -55,6 +55,21 @@ const engine = detectEngine(); // before importing this bundle. declare function print(s: string): void; +// A conformance shell invocation is a bounded worker. Explicit guest threads +// may intentionally remain live after the corpus result is complete, so the +// embedding preamble/shell must end this dedicated process rather than asking +// the runtime scheduler to discard valid background work. Node/Bun install an +// async-safe implementation in host-node.mjs; bare shells provide synchronous +// print() and quit(). +async function endHostProcess(): Promise { + if (typeof g.__polyengineHostEnd === "function") { + await g.__polyengineHostEnd(); + } else if (typeof g.quit === "function") { + g.quit(0); + } + throw new Error("shell host did not terminate after the complete result"); +} + function readBinary(path: string): Uint8Array { const abs = path.startsWith("/") ? path : `${repoRoot}/${path}`; switch (engine) { @@ -88,7 +103,9 @@ function engineVersionString(): string | null { return `node ${g.process.version} (v8 ${g.process.versions.v8})`; } if (engine === "bun") { - return `bun ${g.process.versions.bun} (webkit ${g.process.versions.webkit ?? "?"})`; + return `bun ${g.process.versions.bun} (webkit ${ + g.process.versions.webkit ?? "?" + })`; } return typeof g.version === "function" ? g.version() : null; } @@ -213,7 +230,9 @@ async function probeCapabilities(): Promise { jspi, multiMemory: probeValidate("tools/shell/probes/multi-memory.wasm"), wasmGc: probeValidate("tools/shell/probes/wasm-gc.wasm"), - exceptionHandling: probeValidate("tools/shell/probes/exception-handling.wasm"), + exceptionHandling: probeValidate( + "tools/shell/probes/exception-handling.wasm", + ), memory64: probeValidate("tools/shell/probes/memory64.wasm"), tailCalls: probeValidate("tools/shell/probes/tail-calls.wasm"), relaxedSimd: probeValidate("tools/shell/probes/relaxed-simd.wasm"), @@ -294,6 +313,7 @@ async function main() { } emit("done", {}); + await endHostProcess(); } await main(); diff --git a/tools/shell/host-node.mjs b/tools/shell/host-node.mjs index 9c58f2f7..bd8b1283 100644 --- a/tools/shell/host-node.mjs +++ b/tools/shell/host-node.mjs @@ -18,12 +18,20 @@ import * as fs from "node:fs"; // `new Uint8Array(buf)` TypedArray-copy constructor yields a fresh // zero-offset ArrayBuffer. (Bun's readFileSync also returns a Buffer; the // copy is correct there too.) -globalThis.__polyengineHostRead = (path) => new Uint8Array(fs.readFileSync(path)); +globalThis.__polyengineHostRead = (path) => + new Uint8Array(fs.readFileSync(path)); -// Neither runtime has the shells' global print(); a stdout line via -// console.log is all entry.ts's emit() needs. -globalThis.print = (s) => console.log(s); +// Write protocol records synchronously. In these bounded worker processes the +// entry explicitly exits after `done`; console.log may still be buffered when +// stdout is a pipe, which would make process.exit truncate a valid result. +globalThis.print = (s) => fs.writeSync(1, `${s}\n`); + +// The shell process, not the runtime scheduler, owns corpus-run lifetime. +// Valid cases can leave explicit guest threads alive after their final result. +// Because print() above completes each write before returning, it is safe to +// terminate immediately after entry.ts emits its final `done` record. +globalThis.__polyengineHostEnd = () => process.exit(0); // The .mjs copy (see bundle.ts): with no package.json above it, node parses // a .js file as CommonJS and would reject the bundle's import/export syntax. -await import("./dist/entry.mjs"); +await import(process.argv[2] ?? "./dist/entry.mjs"); diff --git a/tools/shell/run-lane.ts b/tools/shell/run-lane.ts index ca05a54b..0411f01a 100644 --- a/tools/shell/run-lane.ts +++ b/tools/shell/run-lane.ts @@ -201,9 +201,18 @@ async function runShell( const SENTINEL = "@polyengine:"; -function parseProtocol(stdout: string): { header: Header; files: ShellFile[] } { +export function parseProtocol( + stdout: string, +): { + header: Header; + files: ShellFile[]; + doneCount: number; + malformedCount: number; +} { let header: Header = null; const files: ShellFile[] = []; + let doneCount = 0; + let malformedCount = 0; for (const line of stdout.split("\n")) { if (!line.startsWith(SENTINEL)) continue; // shells print their own diagnostics too // deno-lint-ignore no-explicit-any @@ -211,7 +220,8 @@ function parseProtocol(stdout: string): { header: Header; files: ShellFile[] } { try { obj = JSON.parse(line.slice(SENTINEL.length)); } catch { - continue; // a truncated/interleaved line; not this driver's problem to fix + malformedCount++; + continue; } // entry.ts's `emit(kind, payload)` merges `{kind, ...payload}` onto one // line — the header event's fields (engine/capabilities/etc) are @@ -219,8 +229,52 @@ function parseProtocol(stdout: string): { header: Header; files: ShellFile[] } { // `header` key (only the `file` event nests its payload, under `file`). if (obj.kind === "header") header = obj; else if (obj.kind === "file" && obj.file) files.push(obj.file); + else if (obj.kind === "done") { + if (Object.keys(obj).length === 1) doneCount++; + else malformedCount++; + } } - return { header, files }; + return { header, files, doneCount, malformedCount }; +} + +export function protocolCompletionError( + parsed: ReturnType, + expectedFiles: readonly string[], + shellExitCode: number, +): string | null { + if (shellExitCode !== 0) return `shell exited with code ${shellExitCode}`; + if (parsed.malformedCount !== 0) { + return `shell emitted ${parsed.malformedCount} malformed protocol record(s)`; + } + if (parsed.doneCount !== 1) { + return `shell emitted ${parsed.doneCount} done records (expected exactly 1)`; + } + if (!parsed.header) return "shell completed without a header record"; + if (parsed.header.fileCount !== expectedFiles.length) { + return `shell header declared ${parsed.header.fileCount} files; manifest has ${expectedFiles.length}`; + } + + const actualPaths = parsed.files.map((file) => file.path); + const seen = new Set(); + const duplicates = actualPaths.filter((path) => { + if (seen.has(path)) return true; + seen.add(path); + return false; + }); + if (duplicates.length !== 0) { + return `shell reported duplicate file(s): ${ + [...new Set(duplicates)].join(", ") + }`; + } + const expected = new Set(expectedFiles); + const extras = actualPaths.filter((path) => !expected.has(path)); + const missing = expectedFiles.filter((path) => !seen.has(path)); + if (extras.length !== 0 || missing.length !== 0) { + return `shell file set differed from manifest; missing: ${ + missing.join(", ") || "(none)" + }; extra: ${extras.join(", ") || "(none)"}`; + } + return null; } async function main() { @@ -256,7 +310,13 @@ async function main() { const { code, stdout, stderr } = await runShell(args.lane, shellBin, libPath); const wallMs = Math.round(performance.now() - wall0); - const { header, files } = parseProtocol(stdout); + const parsed = parseProtocol(stdout); + const { header, files } = parsed; + const manifest: { files: string[] } = JSON.parse( + await Deno.readTextFile( + join(repoRoot, "harness", "generated", "manifest.json"), + ), + ); console.log(`\n=== lane: ${args.lane} ===`); console.log(`engine : ${header?.engine ?? "(none — no header line)"}`); @@ -269,11 +329,23 @@ async function main() { console.log(`notes : ${exp.notes}`); if (files.length === 0) { - console.error(`\nshell stderr (first 4000 chars):\n${stderr.slice(0, 4000)}`); - console.error(`\nshell stdout (first 2000 chars):\n${stdout.slice(0, 2000)}`); + console.error( + `\nshell stderr (first 4000 chars):\n${stderr.slice(0, 4000)}`, + ); + console.error( + `\nshell stdout (first 2000 chars):\n${stdout.slice(0, 2000)}`, + ); fail(`no files ran (shell exit ${code})`); } + const completionError = protocolCompletionError(parsed, manifest.files, code); + if (completionError) { + console.error( + `\nshell stderr (first 4000 chars):\n${stderr.slice(0, 4000)}`, + ); + fail(completionError); + } + const { summary, unexpectedFailures, staleDeltas } = classify(files, exp); console.log(`\n${summary.format()}\n`); @@ -317,7 +389,9 @@ async function main() { bad = true; } if (staleDeltas.length > 0) { - console.error(`\n${staleDeltas.length} STALE OVERLAY DELTA(S) (predicted, did not occur):`); + console.error( + `\n${staleDeltas.length} STALE OVERLAY DELTA(S) (predicted, did not occur):`, + ); for (const d of staleDeltas) { console.error(` ${d.file}:${d.line} [${d.kind}] ${d.reason}`); } @@ -338,7 +412,9 @@ async function main() { } console.error( `\n[shell-lane] ${args.lane}: ${ - exp.required ? "FAILED" : "deviations recorded (findings lane, not gating)" + exp.required + ? "FAILED" + : "deviations recorded (findings lane, not gating)" }`, ); // Required lanes (sm-pinned, jsc-pinned) gate the per-push core job — a diff --git a/tools/shell/run-lane_test.ts b/tools/shell/run-lane_test.ts new file mode 100644 index 00000000..ec580a79 --- /dev/null +++ b/tools/shell/run-lane_test.ts @@ -0,0 +1,154 @@ +import { fromFileUrl, join } from "jsr:@std/path@1"; +import { parseProtocol, protocolCompletionError } from "./run-lane.ts"; + +const sentinel = "@polyengine:"; +const expectedFiles = ["one.json", "two.json"]; + +function assertEquals(actual: unknown, expected: unknown): void { + if (!Object.is(actual, expected)) { + throw new Error(`expected ${String(expected)}, got ${String(actual)}`); + } +} + +function assertStringIncludes(actual: string, expected: string): void { + if (!actual.includes(expected)) { + throw new Error(`expected output to include ${JSON.stringify(expected)}`); + } +} + +function protocol(...records: string[]): string { + return records.map((record) => `${sentinel}${record}`).join("\n"); +} + +function completionError(output: string, exitCode = 0): string | null { + return protocolCompletionError( + parseProtocol(output), + expectedFiles, + exitCode, + ); +} + +Deno.test("protocol rejects duplicate and missing files despite matching count", () => { + const output = protocol( + '{"kind":"header","fileCount":2}', + '{"kind":"file","file":{"path":"one.json"}}', + '{"kind":"file","file":{"path":"one.json"}}', + '{"kind":"done"}', + ); + assertEquals( + completionError(output), + "shell reported duplicate file(s): one.json", + ); +}); + +Deno.test("protocol rejects a header count that disagrees with the manifest", () => { + const output = protocol( + '{"kind":"header","fileCount":1}', + '{"kind":"file","file":{"path":"one.json"}}', + '{"kind":"file","file":{"path":"two.json"}}', + '{"kind":"done"}', + ); + assertEquals( + completionError(output), + "shell header declared 1 files; manifest has 2", + ); +}); + +Deno.test("protocol rejects unknown and missing files despite matching count", () => { + const output = protocol( + '{"kind":"header","fileCount":2}', + '{"kind":"file","file":{"path":"one.json"}}', + '{"kind":"file","file":{"path":"unknown.json"}}', + '{"kind":"done"}', + ); + assertEquals( + completionError(output), + "shell file set differed from manifest; missing: two.json; extra: unknown.json", + ); +}); + +Deno.test("protocol requires exactly one done record", () => { + const base = [ + '{"kind":"header","fileCount":2}', + '{"kind":"file","file":{"path":"one.json"}}', + '{"kind":"file","file":{"path":"two.json"}}', + ]; + assertEquals( + completionError(protocol(...base)), + "shell emitted 0 done records (expected exactly 1)", + ); + assertEquals( + completionError(protocol(...base, '{"kind":"done"}', '{"kind":"done"}')), + "shell emitted 2 done records (expected exactly 1)", + ); +}); + +Deno.test("protocol rejects malformed records and nonzero shell exit", () => { + const complete = protocol( + '{"kind":"header","fileCount":2}', + '{"kind":"file","file":{"path":"one.json"}}', + '{"kind":"file","file":{"path":"two.json"}}', + '{"kind":"done"}', + ); + assertEquals( + completionError(`${complete}\n${sentinel}{"kind":"done"`, 0), + "shell emitted 1 malformed protocol record(s)", + ); + assertEquals( + completionError( + `${complete}\n${sentinel}{"kind":"file","file":`, + 7, + ), + "shell exited with code 7", + ); + assertEquals( + completionError( + protocol( + '{"kind":"header","fileCount":2}', + '{"kind":"file","file":{"path":"one.json"}}', + '{"kind":"file","file":{"path":"two.json"}}', + '{"kind":"done","unexpected":true}', + ), + ), + "shell emitted 1 malformed protocol record(s)", + ); +}); + +Deno.test("node host exits after complete output despite live background work", async () => { + const here = fromFileUrl(new URL(".", import.meta.url)); + const repo = join(here, "..", ".."); + const pinnedNode = join(repo, ".shell-cache", "node-pinned", "bin", "node"); + let node = pinnedNode; + try { + await Deno.stat(pinnedNode); + } catch { + node = "node"; + } + const fixture = + new URL("./tests/background-complete.mjs", import.meta.url).href; + const child = new Deno.Command(node, { + args: [join(here, "host-node.mjs"), fixture], + stdout: "piped", + stderr: "piped", + }).spawn(); + const timed = await Promise.race([ + child.output(), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("node host did not exit after done")), + 5_000, + ) + ), + ]); + assertEquals(timed.code, 0); + const stdout = new TextDecoder().decode(timed.stdout); + assertStringIncludes(stdout, `${sentinel}{"kind":"done"}\n`); + assertEquals( + protocolCompletionError( + parseProtocol(stdout), + ["background.json"], + timed.code, + ), + null, + ); +}); diff --git a/tools/shell/tests/background-complete.mjs b/tools/shell/tests/background-complete.mjs new file mode 100644 index 00000000..9b7fea91 --- /dev/null +++ b/tools/shell/tests/background-complete.mjs @@ -0,0 +1,5 @@ +print('@polyengine:{"kind":"header","fileCount":1}'); +print('@polyengine:{"kind":"file","file":{"path":"background.json"}}'); +setInterval(() => {}, 1_000); +print('@polyengine:{"kind":"done"}'); +await globalThis.__polyengineHostEnd(); From 19644a4f028ffacfac668690506a84fc4045b9d8 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Mon, 14 Sep 2026 11:47:04 -0400 Subject: [PATCH 2/2] harness: run provider integration after Wasmtime corpus generation --- harness/tests/wasmtime_expectations_test.ts | 41 ----------------- .../tests/wasmtime_provider_integration.ts | 45 +++++++++++++++++++ justfile | 1 + 3 files changed, 46 insertions(+), 41 deletions(-) create mode 100644 harness/tests/wasmtime_provider_integration.ts diff --git a/harness/tests/wasmtime_expectations_test.ts b/harness/tests/wasmtime_expectations_test.ts index f02a3457..b3c3b69a 100644 --- a/harness/tests/wasmtime_expectations_test.ts +++ b/harness/tests/wasmtime_expectations_test.ts @@ -5,9 +5,6 @@ import { WASMTIME_SKIP_EXPECTATIONS, } from "../src/wasmtime-expectations.ts"; import { classify } from "../src/wasmtime-classifier.ts"; -import type { WastJson } from "../src/schema.ts"; -import { runWastJson } from "../src/runner.ts"; -import { RuntimeExecutor } from "../src/runtime-executor.ts"; function assert(condition: boolean, message: string): void { if (!condition) throw new Error(message); @@ -179,41 +176,3 @@ Deno.test("spectest exposes gc only for the deferred-frame boundary fixture", as "native table-capacity control must not be emulated", ); }); - -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, - ); - const doc = JSON.parse( - await Deno.readTextFile( - new URL("context-in-resource-drop.json", generated), - ), - ) as WastJson; - const probe = wasmtimeSpectest("async/context-in-resource-drop.json"); - const executor = await RuntimeExecutor.create( - await Deno.readFile( - new URL( - "target/wasm32-unknown-unknown/release/translator_shim.wasm", - root, - ), - ), - probe.imports, - ); - const result = await runWastJson( - doc, - (name) => Deno.readFile(new URL(name, generated)), - executor, - ); - - assert( - result.results.every((row) => row.status === "passed"), - `fixture did not pass: ${JSON.stringify(result.results)}`, - ); - assert( - probe.counters.forcedHostBoundaries === 4, - `expected four destructor host-boundary calls, got ${probe.counters.forcedHostBoundaries}`, - ); -}); diff --git a/harness/tests/wasmtime_provider_integration.ts b/harness/tests/wasmtime_provider_integration.ts new file mode 100644 index 00000000..f25c70c4 --- /dev/null +++ b/harness/tests/wasmtime_provider_integration.ts @@ -0,0 +1,45 @@ +import type { WastJson } from "../src/schema.ts"; +import { runWastJson } from "../src/runner.ts"; +import { RuntimeExecutor } from "../src/runtime-executor.ts"; + +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, + ); + const doc = JSON.parse( + await Deno.readTextFile( + new URL("context-in-resource-drop.json", generated), + ), + ) as WastJson; + const probe = wasmtimeSpectest("async/context-in-resource-drop.json"); + const executor = await RuntimeExecutor.create( + await Deno.readFile( + new URL( + "target/wasm32-unknown-unknown/release/translator_shim.wasm", + root, + ), + ), + probe.imports, + ); + const result = await runWastJson( + doc, + (name) => Deno.readFile(new URL(name, generated)), + executor, + ); + + assert( + result.results.every((row) => row.status === "passed"), + `fixture did not pass: ${JSON.stringify(result.results)}`, + ); + assert( + probe.counters.forcedHostBoundaries === 4, + `expected four destructor host-boundary calls, got ${probe.counters.forcedHostBoundaries}`, + ); +}); diff --git a/justfile b/justfile index d30b82c7..23938555 100644 --- a/justfile +++ b/justfile @@ -158,6 +158,7 @@ conformance: # harness/generated-wasmtime/results.json. test-wasmtime: shim cd harness && deno task wasmtime + cd harness && deno test --allow-read=.. tests/wasmtime_provider_integration.ts # Build the two upstream Wasmtime async guests (round-trip, short reads) # from the same locked wasmtime-environ revision, into the ignored