A WebAssembly Component Model host built on the JS WebAssembly API, targeting
JSPI-capable engines. Primary development against Deno; conformance runs
against browsers. Aims for Component Model feature parity and compatibility
with wasmtime and wit-bindgen. 0.3.0 concurrency is the core deliverable.
This document records the architecture and the decisions made, with rationale. Section numbers §1–§11 are stable — they are cited from code comments and contracts throughout the repo. Related documents:
milestones.md— the verified milestone recordconsumers.md— the polymorph adoption track (jco replacement)references.md— canonical upstream links../contracts/— versioned interface contracts../AGENTS.md— the development protocol- open work: the issue tracker
- Concurrency is the point. Existing hosts already run non-async
components fine; this project exists to be a first-class host for Component
Model 0.3.0 concurrency —
asynclift/lower, tasks/subtasks,stream/future— mapped natively onto the JS event loop and JSPI. Sync-only operation is a supported subset, not a destination. - Load, link, and run Component Model binaries (
.wasmcomponents) at runtime on stock JS engines, using only the JSWebAssemblyAPI. - Component Model feature parity with wasmtime, tracked against the official spec (WebAssembly/component-model).
- Compatibility with wasmtime-built and wit-bindgen-built guest components, sync and async alike, made executable via imported conformance/test suites. componentize-go output is a named second guest toolchain (consumer-driven, consumers.md): callback-ABI async lifts + async-lowered imports from Go's patched runtime — a differently-shaped exerciser of the same ABI, which wasmtime runs correctly and jco does not.
- Adoption target: replace jco as the JS host for the polymorph component family and experiment-mosh (consumers.md). Their JS-host legs were blocked on structural jco defects in exactly this project's core territory (0.3 concurrency). They have no external dependents, so embedder conventions co-evolve with them — designed against real consumers, not in the abstract. Success = their conformance matrices green on this host and the jco fork pins deleted.
- "Parity" means functional parity, not behavioral identity. The bar is:
the same feature set, spec-conforming behavior, and wasmtime/wit-bindgen
guests running correctly. Where the spec sanctions a range of behaviors,
this host may — and does — diverge from wasmtime's choices (deterministic
FIFO scheduling per §6; deterministic NaN profile; JS-native host value
shapes). Wasmtime-identical observable behavior is adopted only where
(a) something external forces it — the official suite's
assert_trapmatches message text, which is de facto wasmtime wording — or (b) it is free by construction (the translation frontend is wasmtime-environ, §4). Behavior mandated by the spec/reference (e.g. instance poisoning on trap, per definitions.py) is spec conformance, not wasmtime-matching, even when wasmtime exhibits it too. The tie-breaking authority for semantic questions is the spec +definitions.py, with wasmtime as corroborating evidence — never the other way around. One bounded exception (operator decision, 2026-08-09): wheredefinitions.pycontradicts the spec repo's own wast corpus and wasmtime implements the corpus side, the corpus semantics — as wasmtime actually implements it, verified against wasmtime source or trace, not as inferred from the test alone — is adopted as the working assumption. Each such case must be a named finding inupstream-component-model-repo-findings.md(currently CM-3 only) and flips back if upstream adjudicates the other way. Bare wasmtime behavior with no corpus backing never supersedes the reference. Guard before invoking the exception (the CM-4 lesson, adjudicated 2026-08-10 — polyengine#43): a corpus assertion counts as semantic authority only if it is schedule-independent.sync-streams.wast:145turned out to pin a scheduler policy, not semantics — two conforming policies over the same agreed gate semantics answer it differently — so it is an upstream test defect and no semantics were ever in conflict; polyengine satisfies it via a non-normative scheduler policy (hold gate + drain-to-quiescence entry decision, PR #45). - TypeScript throughout the JS side: the runtime, the harness, and all generated bindings.
- A performance story that can get fast later without rearchitecting.
- WASI implementations in the core.
wasi:*host packages are out of scope for the runtime itself; the finish line is Component Model support, demonstrated with custom WIT worlds. Two sanctioned carve-outs, both outside the core: WASI interface shapes are first-class design inputs to the embedder conventions (consumers.md; milestone C1) — they are the ecosystem's most important interfaces and the conventions must serve them well — and a WASI provider package (wasi/,@polyengine/wasi; namedwasi-shimsuntil 2026-08-14, when the contents outgrew "shim" — a separate deliverable; consumer-driven scope: p2 cli/io/clocks/random baseline + p3 clocks, plus à la carte network fragments the defaultwasi()merge never carries: p3wasi:sockets(@polyengine/wasi/sockets— UDP adopted from polymorph-iroh's Deno host, TCP client + listener (the wosh consumer / the A13 resource-stream accept path); one node-builtins backend serving Deno via its stable node compat — no--unstable-net— and real Node, #4) and the fetch-backedwasi:http@0.3outbound client (@polyengine/wasi/http, riding the@0.3track like the rest of the package; aversionoverride keys exact ids for guests pinned to pre-consolidation rc snapshots)). - Componentizing JS/TS. Guests are components built by external toolchains (Rust + wit-bindgen is the reference). No embedded-JS-engine work.
- jco compatibility or reuse. Ignored entirely — including at the
embedder-API level: we do not emulate jco's host conventions (thrown
bare
{tag, val}payloads, itsStreamobjects, transpile-time async enumerations); consumers port to our conventions (consumers.md). Where we need prior art we take it from wasmtime; where jco's conventions have known footguns (documented defensively by the polymorph host modules themselves), we fix rather than inherit. Replacing jco for the named consumers is a goal (§1); being jco is not. - Pre-JSPI engines. No JSPI fallback path for the stackful/blocking forms. (The callback ABI — what wit-bindgen and componentize-go actually emit — needs no JSPI at all, so the effective floor for consumer workloads is "any modern engine"; JSPI is required only for sync-blocking forms.)
Floor for the JSPI-dependent forms: engines supporting JSPI (proposal is
phase 4; minor API drift is still possible — it changed once already when
the Suspender object was removed).
| Engine | JSPI status | Role |
|---|---|---|
| Deno ≥ 2.3.2 | on by default | primary dev target |
| Chrome/Chromium ≥ 137 | on by default | browser lane (exact Deno parity) |
| Firefox | flag: javascript.options.wasm_js_promise_integration |
browser lane (pref flipped by the driver) |
| Safari / WebKit | works unflagged on WPE 26.5 (M3 finding); JSPI + multi-memory both present in Safari Technology Preview as of STP 250 (2026-08-13; multi-memory flip is 317636@main); stable-Safari status: #11 |
browser lane; pinned build capped by JSC's missing multi-memory — implemented and default-on in WebKit trunk (webkit-2342+ rolls reach 1248/0, effective parity; #11) |
| Node | on by default ≥ 26 | pinned runtime lane (node-pinned, v26.x: exact Deno parity, no flags; required gate). Node 24 LTS deliberately not laned: flag-gated JSPI (--experimental-wasm-jspi) whose older V8 13.6 vintage deviates on 2 corpus commands — see harness/shell/expectations/node-pinned.ts |
| Bun | on by default (1.3.x, vendored JSC) | pinned runtime lane (bun-pinned, findings-only until a track record): exact Deno parity under BUN_JSC_useWasmMultiMemory=1 (driver-set; stock bun ships multi-memory off → 174 failures) — see harness/shell/expectations/bun-pinned.ts |
Notes:
- Deno and Chrome share V8, so Firefox and WebKit provide the real engine
diversity. M3 evidence: SpiderMonkey JSPI is clean over the full corpus;
JSC's JSPI works unflagged, but the pinned JSC build lacks multi-memory,
the actual WebKit-lane cap (the CABI routinely needs >1 memory per core
module; default-on in WebKit trunk, #11). Engine trap-message wording
differences are normalized in the harness matcher, never in the runtime
(
TRAP_MESSAGE_EQUIVALENTS, harness/src/runner.ts) — with them reconciled, Firefox and trunk WebKit run at exact Deno-lane parity. - The node/bun lanes add embedding coverage, not engine coverage (V8 and
JSC are already exercised above): module loading, event-loop integration,
and runtime I/O quirks — e.g. node's pooled
Buffer, whose pool-backed.buffermust never reach WebAssembly APIs (tools/shell/host-node.mjs). - Type reflection (js-types) is phase 3 and flagged everywhere — function
signatures are not available from
WebAssembly.Module.imports(). The architecture below sidesteps this (the translator emits all type information), but no design may assume type reflection exists. - CSP: compiling from bytes requires
wasm-unsafe-eval. The runtime requires nothing beyond that — a design invariant, not a default (decided 2026-08-10, #8): no code path may require fullunsafe-eval. The specialized-JS executor is emission-only (§8) — a deploy-time AOT step or a server-side first-load cache import — never runtimeeval/new Function. - The runtime core is platform-neutral by contract (§4.3) and this is pinned
by
runtime/tests/platform_purity_test.ts— nonode:*builtins, no Deno APIs (M3 removed the one violation, anAsyncLocalStorageambient).
One deterministic pipeline, run either at first load (and cached) or ahead of time — "AOT" and "runtime linking" are the same code executed at different moments.
Rust (compiled to wasm32, runs everywhere)
┌──────────────────────────────────────────┐
component.wasm ───► │ translator = wasmtime-environ (validate, │
│ resolve linkage) + FACT (fused adapters) │
│ + shim (stable output format) │
└──────────────┬───────────────────────────┘
│ artifacts (bytes, content-addressed)
▼
┌────────────────────────────────────────────────────┐
│ plan: instantiation ops, type tables, CABI │
│ descriptors, required-intrinsics list │
│ core modules: byte ranges sliced from the component │
│ adapter modules: FACT-generated core wasm │
└──────────────┬─────────────────────────────────────┘
│
▼ TypeScript (platform-neutral)
┌────────────────────────────────────────────────────┐
│ runtime: plan executor, host-boundary lift/lower, │
│ resource tables, intrinsics, reentrance gates, │
│ JSPI trampolines, 0.3 task scheduler (core) │
└────────────────────────────────────────────────────┘
wit/*.wit ──► bindgen (Rust, wit-bindgen-core) ──► typed TS bindings
(verified against the plan at instantiate())
We reuse wasmtime's "decide what to do" layer, which is separable from its "do it" layer and has no native-code dependency:
wasmtime-environ's component translator: parsing, validation, subtyping, and resolution of the component's linking structure into a flat instantiation plan.wasmtime-environ::fact(FACT): generates fused adapters — the glue for cross-component calls (canonical-ABI lift composed with lower) — as plain core wasm modules viawasm-encoder.
Why this is the cornerstone decision:
- Wasmtime compatibility by construction. We inherit wasmtime's interpretation of the spec for the largest correctness surface (validation, types, adapter semantics) and turn "compat with wasmtime" into a version pin.
- It solves the JSPI stack-purity problem (§5) by construction — all cross-component call paths are wasm, never JS.
- It removes the hardest codegen (flattening, param spilling, string transcoding, resource transfer, post-return) from our scope.
Constraints and mitigations:
wasmtime-environis an internal, unstable API. Mitigation: a thin Rust shim crate owns the dependency and maps environ's output into our own stable plan format. Wasmtime churn is confined to the shim. Pin wasmtime and wasm-tools versions; upgrade deliberately (the staged bump is #1).- FACT adapters import host intrinsics (string transcoders,
resource-transfer-own/borrow, enter/exit bookkeeping, trap). The TS runtime implements this contract — specified in contracts/intrinsics.md — and the shim emits the required-intrinsics list per component so the contract is explicit at translation time, not discovered at instantiation. These intrinsics are synchronous JS calls that return before any suspension can occur — compatible with the JSPI frame rule. - The translator ships as a plain core wasm module with a bytes-in/bytes-out ABI (no components-all-the-way-down bootstrap).
- Size: 1.66 MiB size-tuned (~0.5 MiB gzip), sub-ms steady-state translation; multi-MB consumer components translate in tens of ms (measured at C0: 10.5 MB in 73 ms warm). Being a real static asset ≥ 128 kB, browsers code-cache the translator itself well — the most expensive fixed cost of the pipeline is the part engines already handle.
The go/no-go spike for this decision (S0) and its fallback plan (vendor FACT,
drive wasmparser directly) are recorded in
milestones.md — the spike passed; the fallback was never
needed.
Specified in contracts/plan-format.md (v0 pinned at M0). Summary of the fixed decisions:
- Defined by us, versioned, operational content only: instantiation ops, core-module slice ranges, adapter module references, canonical-ABI descriptors for host-boundary functions, type tables, required intrinsics, resource-type metadata (dtor references).
- No WIT-level fidelity (no docs, no feature gates, no aliasing structure) — bindings generation reads WIT source instead (§9). This keeps the format small and stable.
- Encoding: JSON (the simplest thing that round-trips); revisit only if measurable.
- Deterministic: identical inputs (component bytes, translator build, flags) produce identical artifacts. This is what makes caching trivial (§10).
Platform-neutral core (dependencies: WebAssembly JS API, TextEncoder/
TextDecoder, Promises — nothing else; pinned by
runtime/tests/platform_purity_test.ts). Responsibilities:
- Plan executor: compile sliced core modules and adapters, instantiate in plan order, wire imports/exports.
- Host boundary: lift/lower per CABI descriptors (§8),
realloc/post-returnhandling. - Resource machinery: slab handle tables, own/borrow tracking (
num_lends, borrow invalidation at call return), dtor invocation (§7), FACT intrinsic implementations. - Reentrance gates:
may_enter/may_leaveenforcement — JSPI happily permits reentry that the Component Model forbids; the gates are ours to enforce and must hold while suspended. - Task scheduler (§6): the 0.3 task/thread model is the runtime's core structure, not an add-on — waitable sets, streams/futures, callback-ABI event dispatch, backpressure, cancellation. Sync calls are the degenerate case: a task driven to resolution before the call returns, exactly as in the reference implementation.
Above the raw boundary sits the embedder conventions layer
(runtime/src/embedder/, governed by
contracts/embedder-api.md): camelCase facades,
branded ComponentExceptions, resources as classes in both directions, Stream/
Future handles over web-native producers, and semver-canonical import
resolution matching the spec + wasmtime's NameMap.
From the JSPI spec (js-promise-integration Overview):
Only WebAssembly computations may be suspended: only WebAssembly frames may be active between the call to a
promisingfunction and any call to aSuspendingwrapped import — a JS frame in between traps.
Consequences baked into this design:
- Host boundary JS glue is safe. A
Suspending-wrapped import's JS runs to completion and returns a Promise; suspension happens after it returns, so host-side lift/lower in JS never sits on the suspended stack. - Cross-component glue must be wasm. A JS adapter between components A and B would trap the moment anything below it suspends. FACT adapters keep those stacks pure wasm — this is why §4.1 is the cornerstone.
- Component exports invoked from JS that may transitively suspend must be
entered through
WebAssembly.promisingtrampolines. This includes JS-initiated resource drops (§7). - Guest-initiated cross-component dtor calls route through generated wasm (direct funcref call in the adapter/intrinsic path), not a JS bounce.
Existing hosts handle non-async components adequately; 0.3.0 concurrency is
why this project exists. The runtime is therefore designed around the 0.3
task model from day one — sync-only operation falls out as the degenerate
case, exactly as in the reference implementation (definitions.py, where
canon_lift always creates a Task/Thread and the sync path is a driving loop
over the same structures). This ordering was deliberate: retrofitting the task
model onto a sync-first runtime is the rearchitecting we were not allowed to
need. (It is also, empirically, the rearchitecting jco is stuck in — see
consumers.md.)
Mapping the reference model onto the web platform:
| Reference concept | Implementation |
|---|---|
Thread (suspendable computation) |
wasm activation entered via WebAssembly.promising |
Thread.wait_until / blocking |
call to a Suspending import returning a scheduler-controlled Promise |
| resume | scheduler resolves that Promise (event-loop turn) |
| scheduler | JS event loop + explicit ready queues; cooperative, matching the CM model — no preemption exists or is needed |
Waitable / WaitableSet |
host-side event structures; wait = suspension (stackful) or the callback return-code protocol (stackless) |
| callback ABI | no suspension at all: the scheduler invokes the callback export with events |
sync canon_lift driving loop |
same scheduler: pump ready threads until resolved, with the spec's deadlock trap |
Subtask, backpressure, cancellation |
direct ports of the reference structures |
JSPI's three roles, precisely:
- Stackful async lifts (no-callback
async) — the guest blocks mid-stack. - Blocking sync lowers — a caller waiting on an unresolved subtask
(
thread.wait_until(subtask.resolved)in the reference). - Sync guests over async host imports — falls out of the same mechanism; a useful capability, not a separate deliverable.
The callback ABI needs no JSPI (stackless by design). Empirical confirmation (S0 fixtures, reconfirmed by every consumer artifact since): wit-bindgen emits exclusively callback-ABI async lifts, and componentize-go likewise — running real async guests requires the task core, not JSPI.
Determinism: the reference scheduler makes explicitly nondeterministic
choices (random.choice over ready threads). Decided (M2): deterministic
FIFO ready-queue by default; a seeded-shuffle mode (POLYENGINE_SCHED_SEED env
var) exercises the spec-allowed nondeterminism in tests, verified across
seeds. Documented at runtime/src/task/scheduler.ts. A load-bearing
architectural rule discovered post-M2: one driver per store — concurrent
driveAsync loops can double-resume threads; between export calls the two
fallback drivers stand down whenever an export-call driver is live (the
invariant and its benignity argument are documented at the site in
runtime/src/exec/boundary.ts). There are exactly three drivers: export
calls, the host-activity pump (embedder stream/future operations landing
between calls), and — since embedder-api amendment A11 — the settlement
pump, which services host-import settlements that land while the store is
driver-idle. The settlement pump is what gives background tasks host-driven
liveness between export calls (a task parked on a waitable set whose pending
host call is a clock resumes at settlement time); wasmtime only delivers
such wakeups while the embedder dwells in run_concurrent, but a JS host's
event loop is always dwelling, so polyengine makes it unconditional.
Named divergence (2026-08-10, #92):
the async form of subtask.cancel is not atomic under jspi. The
reference built-in returns [BLOCKED] with no suspension; polyengine parks the
caller on a determinacy wait so the BLOCKED/resolved answer matches the
reference's synchronous-delivery outcomes across the engine's mandatory
microtask hop (jspi pin (j), pinned by cancellable.wast). While parked,
other ready threads of the store may run, so sibling-task effects can become
observable across the single built-in call — a reordering within the
reference's own Store.tick freedom, taken one built-in early; every
interleaved sibling was already at a block point. Rationale and mechanics at
the site (runtime/src/intrinsics/async_builtins.ts, the determinacy park
in createSubtaskCancel); regression pinned across seeds by
runtime/tests/cancel_bracket_race_test.ts.
Host-import cancellation resolves promptly by default (A23). The
reference leaves a host callee's on_cancel to the embedding
(Store.invoke, definitions.py line 572); wasmtime hosts hand back a
future whose drop is cancellation. A JS Promise offers no such channel,
so polyengine's lowered host imports answer with the reference's
prompt-cancel shape — on_cancel = () => on_resolve(None) — resolving the
subtask CANCELLED_BEFORE_RETURNED and discarding the promise's eventual
settlement (never lowered, rejections unreported, deregistered from
deadlock accounting). This is a reference-legal host behavior, not a
divergence; the per-declaration deferCancel() brand
(contracts/embedder-api.md A23) restores run-to-completion for imports
with commit points. The host operation itself is never interrupted — only
delivery is cancelled. Amendment A24 closes that gap for hosts that can be
stopped: an import marked abortable() receives a fresh AbortSignal
appended after its WIT-declared parameters on every call, and the runtime
aborts it a microtask after the discard — never synchronously inside
canon_subtask_cancel, so a host abort listener never runs inside a live
guest activation. Whatever settlement the abort provokes (typically an
AbortError rejection) arrives with the subtask already resolved and lands
on A23's resolved-subtask guards, discarded like any other late settlement.
Named divergence (2026-08-20, #165,
adjudicated-accept): enter-sync-call checks the callee's reentrance gate
but does not take it. A FACT sync guest→guest call performs the
reference's trap_if(not may_enter_from(caller)) and stops — the
enter_from/leave_to bracket around the call body (Store.lift,
definitions.py:578-585) is deliberately omitted, so host-mediated reentry
into the callee while the call is in flight (host → A.f → C.g → host import
→ host re-enters C.g) is admitted where the pinned reference traps. Pure
guest→guest cycles remain statically impossible (FACT compile-time traps;
the instance-import DAG, #99/
#101). Accepted on three
grounds: wasmtime parity (enter_guest_sync_call performs no reentrance
check at all, and fused adapters elide it); architecture — taking the
bracket would create a guest→guest lock spanning suspension points,
reintroducing the await-spanning-lock class that
#156/#160
eliminated; and upstream trajectory — CM PR
#705 ("CABI:
remove the may_enter flag/trap") deletes the trap from the canon lift,
resource.drop, and subtask.cancel paths and makes previously-trapping
reentrance valid, retaining only run-to-completion serialization of async
callback turns. This divergence is therefore a trailing indicator of the
upstream removal and self-resolves when the submodule pin advances past
#705; the pin-advance migration map is
#173. Until that advance the
pinned definitions.py remains the tie-breaker everywhere else — every
reentrance check polyengine does enforce stays in force.
Authority: CanonicalABI.md and its executable reference
(design/mvp/canonical-abi/definitions.py). Where the host has freedom, we
decide deliberately and document here.
- Strings. Component strings are USV sequences; JS strings are WTF-16.
Lowering a JS string with lone surrogates uses WebIDL
USVStringreplacement semantics (U+FFFD). Guest→host lift viaTextDecoder; host→guest lower viaTextEncoder.encodeIntodirectly into guest memory.latin1+utf16implemented in the v1 interpreter (the ported reference tests forced it immediately; wit-bindgen guests themselves use utf8). - Numbers.
u64/s64↔BigInt; everything else ↔number.list<u8>↔Uint8Array(copy; views into guest memory are never exposed — with one deliberate, scoped exception: thestream<u8>direct-access sessions of embedder-api amendment A21 hand the callback a view over the peer guest's landing zone or unread bytes, valid only for that synchronous callback, so an external byte mover's last hop can BE the one ABI copy). Both directions are bulk copies: lift via aUint8Arrayslice, lower viaUint8Array.set(issue #54 — the per-element interpreted store cost ~45 ns/byte and capped host→guest byte traffic at ~22 MB/s). Stream payload copies share these paths, and u8 stream chunks stayUint8Arraythrough host buffers too, so a host-side stream read costs exactly the one rendezvous copy. Lists of the other flat element types (bool, s8, u16–u64/s16–s64, f32/f64) keep their plain-array host shapes but also copy bulk, through TypedArray views with the deterministic profile's NaN canonicalization preserved in both directions (issue #67); the platform's little-endianness is a named assumption checked once, with the DataView per-element path as the big-endian fallback.charstays per-element (its lift is per-element USV validation). - Memory views are re-acquired after any call that can grow memory
(
ArrayBufferdetach onmemory.grow). - Resources. Host-facing handles are classes with
Symbol.dispose(TSusing), an explicit[Symbol.dispose]()/drop(), and aFinalizationRegistrybackstop for leaks. (Backstop-vs-teardown ordering policy: open, #10.) - Destructors. Per spec (CanonicalABI.md §
canon resource.drop): the dtor is a core function[rep] -> [], invoked as a normal non-async cross-component call — "the destructor may not block. However, the destructor may spawn a cooperative thread that does." Reentrance is checked (may_enter_from) with the same-instance exemption, and a trapping dtor poisons the implementing instance (the reference'sStore.liftbracket, reconstructed atruntime/src/cabi/handles.tscallDtorGated— implemented at #85; the same-instance exemption falls out ofentering_set, not a special case). Host policy:- CM-level blocking in a dtor → deterministic trap (falls out of general sync-task rules).
- Host-import latency is invisible to CM semantics; a dtor calling a
Suspendinghost import is legal but needs a suspension-legal stack: JS-initiated drops (using, FinalizationRegistry) enter via apromisingtrampoline (ResourceTypeInfo.dtorHost, wired by the executor in jspi mode for suspension-capable dtors — a non-suspendable dtor keeps the exact synchronous path, avoiding the promising microtask hop's one-turn entered window; the async entry bracket is held until the activation settles, tracked inpendingHostCalls). Known limitation (#85 scope note): a guest-initiated drop reaches the dtor through a JS trampoline frame, not the §5 pure-wasm funcref path — a Suspending import under it is a deterministic JSPI frame-rule trap, not a supported suspension. The pure-wasm dispatch path is future machinery; until then §5's "guest-initiated dtor calls route through generated wasm" is aspiration, not description. - Host-held own handles carry lend tracking mirroring
num_lends(#86): drop/GC-backstop defer while lent; a backstop dtor trap poisons the implementing instance and lands on the host-failure channel (nevercatch {}-swallowed). - Upstream spec findings related to drops and backpressure (vestigial
$async?onresource.drop; the deadcanon_backpressure_setin definitions.py, since removed upstream) are tracked in upstream-component-model-repo-findings.md, the single source for component-model issue/PR filing. Implementation is sync-only drop regardless of upstream timing.
- Component
valueimports/exports (the component-levelvaluedefinition feature): wasmtime doesn't implement them; excluded from parity scope. Note the official suite'stest/values/directory is not this feature — it is plain canonical-ABI value-passing tests (canon liftwith memory options) and is fully in scope (scope ruling corrected during M1; the directory is green). - Reentrance: gates per spec Component Invariants, enforced in the runtime (see §4.3 item 4).
Requirement: not critical now; must become fast without rearchitecting.
- Cross-component calls are already the fast path: FACT adapters, pure wasm, no JS in the hot path. Nothing to do later.
- Host boundary has two executors over one IR — specified in
contracts/descriptor-ir.md (v0 pinned
at M0):
- The shim emits CABI descriptor tables (a compact ops IR per function).
- v1: a generic interpreter walks descriptors. CSP-clean, everywhere. This is what ships today; it has been fast enough for every consumer gate so far.
- v2 (when a gap is measured — gated on
#17, not the calendar;
#8): a generator from the
same descriptors to specialized JS modules — emission-only, never
eval/new Function(§3's CSP invariant). One mechanism, two invocation times: a deploy-time AOT step, or — on server hosts (Deno/Node, no CSP) — first-load emission into a cache directory andimport(); pre-warming and freezing that cache is the AOT step. Browsers running dynamically-loaded components stay on the interpreter. The generated-module contract is AOT-shaped from day one (explicit linking context; no closure capture of live runtime state) so both invocation times share one artifact. Two executors over one IR double as a differential-testing oracle — exercised by importing emitted modules, i.e. the production delivery mechanism itself.
- Disciplines adopted from the start because they're hard to retrofit: slab
handle tables; no per-call closure/object allocation on hot paths; view
reuse with grow-aware invalidation;
encodeIntofor strings. - Future options, noted not planned: JS string builtins (now widely shipped) for string-heavy host boundaries. Deploy-time unbundling (real URLs per module → engine code-cache hits) is a packaging concern independent of the executor choice and moved to the caching track (§10, #7).
- A Rust CLI crate (
crates/bindgen) built on wit-bindgen-core, consumingwit_parser::Resolve+WorldId. WIT source is the input — the plan cannot reproduce high-fidelity bindings (docs are lost in binaries, feature gates are resolved away, aliasing is flattened) and the bindings-before-any-component workflow requires WIT anyway. - Output: TypeScript — typed world/interface APIs,
.d.ts, resource classes (using-compatible), JSDoc from WIT doc comments, honoring@since/@unstablegates. - Generated bindings import the runtime through a configurable base
(
--import-base), defaulting to the versioned JSR specifierjsr:@polyengine/runtime@^<runtime/deno.json version>(derived at build time, never hand-written). A path or URL base addresses files ({base}/{module}/mod.ts); a bare or registry base addresses package exports ({base}/{module}) —--import-base --helpstates the rule and its scheme fallback in full. The in-repo fixtures use the relative base../../../srcsodeno checkstays offline and on this checkout's source. - Host-facing value conventions (error model, stream/future wrappers, variant/option/result shapes, resource classes, module-per-interface authoring) are governed by the embedder conventions contract (contracts/embedder-api.md, milestone C1), which superseded descriptor-ir.md's interim "target table". Bindgen also emits host-side types for import worlds (what an embedder must provide), not only export-side facades — the consumers' host modules (consumers.md) are the reference consumers of that surface.
- Skew protection, the wasmtime way: the generator embeds a canonical
structural digest of the expected world into the bindings
(contracts/digest.md);
instantiate()verifies it against the loaded component's types (already computed by the translator) and fails fast with a useful diff. Compile-time fidelity from WIT; load-time truth from the binary. - Secondary, degraded mode: bindings from a component binary via its decoded types (structure only, no docs). For third-party components; never primary.
- Guest-side bindings are stock wit-bindgen (Rust et al.) — that toolchain is the compatibility target, exercised by its own runtime tests (§11).
- Version pinning: wit-parser/wasm-tools pinned to the same versions as the translator's wasmtime, so WIT feature resolution matches.
Two independent layers; nothing may depend on the second.
- Artifact cache (ours, bytes only).
runtime/src/cache/— content-addressed by(component sha256, translator build hash, features); deterministic translation makes this trivial. Storage: Cache API in browsers (webCache), a cache directory in Deno (dirCache). Skips the translation stage on reload. The plan is stored, never the component bytes (the plan slices the component by offset; whoever holds the cache key already holds the bytes). - Engine code caches (opportunistic). Chrome's wasm code cache is keyed
by URL but anchored to the HTTP resource cache entry (invalidation via
304/200 semantics + V8 version), applies only to
compileStreaming/instantiateStreaming, and only to modules ≥ 128 kB after full tier-up. Consequences:- Service-worker-synthesized responses get streaming compilation but no persistent code cache (no HTTP cache entry to anchor to). Same conclusion in Firefox (alt-data on HTTP cache entries). Safari: no persistent wasm code cache known.
- FACT adapters are kilobytes — under the threshold, never code-cached anyway. Only large sliced core modules matter; recurring cost is re-tier-up CPU, not startup latency (Liftoff is fast).
- If a deployment has a build step: run the translator there (same wasm,
under Deno) and publish artifacts at real URLs → full engine caching with
zero tricks. Optionally warm via service-worker install-time
compileStreamingof those real URLs. - Empirical verification of the code-cache behavior is open: #7.
Trust boundary (recorded lean, not yet forced by anything): trust locally-run
translation; never trust artifacts that did not come from the local cache
keyed by component hash. The runtime re-validates plan structure at load
(strict formatVersion, schema checks) but does not re-verify that artifacts
faithfully derive from the component bytes. Consequence for embedders — write
access to a cache root is worth about what write access to the component files
is worth: see security.md "The artifact cache is a trust input",
which carries the pre-warmed read-only-cache recipe.
No cache failure may fail a translation
(#196): a
get/put/self-heal-eviction failure — an unwritable root included — degrades
to a fresh translation, reported only through translateCached's opt-in
onCacheError. That is what makes a read-only cache root a usable deployment
rather than a crash. The public evict() still throws for explicit callers.
There is no single official conformance suite; the corpus is assembled:
| Source | What | How used |
|---|---|---|
WebAssembly/component-model test/ |
official, growing WAST suite: binary/, validation/, linking/, resources/, values/, async/ |
git submodule; primary gate, all directories in scope. Independent check on the wasmtime-frontend reuse. |
same repo, design/mvp/canonical-abi/definitions.py + run_tests.py |
executable CABI reference | lift/lower edge-case tests ported to TS unit tests (runtime/tests/) |
| wit-bindgen runtime tests | guest programs exercising bindings | Rust guests, sync and async (wit-bindgen + wasm-tools component new), run against our host = the executable wit-bindgen-compat claim (examples/guests/) |
wasmtime tests/misc_testsuite/component-model/ |
engine-grade wast corpus | supplementary coverage |
| polymorph conformance matrices (webcrypto/websocket/webrtc/tls, driven by polymorph-test) | per-interface implementation×environment conformance suites over real WIT surfaces | consumer lane (consumers.md): ct-runner executes them; websocket runs 55/55 today; release-gate wiring is #6 |
experiment-mosh gates + minimized repros (compose-async-tdz) |
composed 3-component client: mixed sync/async exports, background pumps, resources re-exported across interfaces, componentize-go guest | strongest known real-workload exercisers — this family surfaced ≥5 distinct jco defect classes no WAST corpus expresses (tools/smoke-c0/, exams/) |
Harness pipeline: an offline Rust step (crates/testgen) converts .wast
into JSON commands + .wasm binaries — the core-spec wast2json model. It
uses the wast crate directly (resolved: wasm-tools json-from-wast's JSON
model is adequate, but the pinned CLI's bundled parser predates current suite
syntax — 15/59 files vs 59/59 with wast 255; owning the emitter also let us
tag every artifact core vs component, which the harness needs since V8
cannot even validate component binaries). The TS harness executes the JSON
identically under deno test and in browsers (tools/browser/run-lane.ts:
static server + automated Chromium / Firefox-with-pref / WebKit, with
per-lane expectation overlays and stale-delta detection) — and directly
under engine shells and server runtimes (tools/shell/run-lane.ts:
SpiderMonkey js, JSC jsc, and node/bun via a host preamble; same
classification machinery, no browser). Because the corpus is
engine-shaped, the per-push/PR engine gates are the pinned shell lanes
(sm-pinned = the Firefox-release shell matching the browser lane,
jsc-pinned = a sha256-mirrored trunk build, node-pinned = the node ≥ 26
runtime; bun-pinned rides along findings-only until it has a track
record; tools/shell/pins.json);
browser lanes run post-merge, verifying the embedding and shipped-channel
configs and gating the prerelease. Trunk/nightly shells and a Deno-canary
probe run weekly as findings-only canaries (.github/workflows/canary.yml)
with a capability-probe preamble that surfaces wasm-proposal landings
(multi-memory, GC, EH, memory64, …) before the corpus exercises them.
Also planned: differential testing of the interpreter vs emitted
specialized-JS modules (§8, #8);
differential fuzzing
against native wasmtime with wasm-smith-generated components
(#9).
Epistemic note: because our frontend is wasmtime's, wasmtime-derived tests partly test wasmtime against itself — weight the official suite and definitions.py ports accordingly. And passing suites is necessary, not sufficient: the consumer workloads found real defects (in jco and in this runtime) that no WAST corpus expresses — which is why their suites and exams are kept as gates.
| Risk | Severity | Mitigation |
|---|---|---|
wasmtime internal API churn (wasmtime-environ is internal/unstable) |
medium, recurring | shim isolation + version pinning; upgrades are deliberate events (#1) |
| JSPI phase-4 drift | medium | small trampoline surface, centralized; track proposal |
| Safari: stable-channel JSPI status unverified | accepted | floor is explicit; JSPI unflagged on WPE 26.5, and STP 250 carries JSPI + multi-memory — stable channel is the remaining gap (#11); callback-ABI consumers don't need it |
| JSC/SpiderMonkey engine gaps | medium | Deno-first dev; file upstream. M3 evidence: SpiderMonkey JSPI clean over the full corpus (pref-flipped); JSC's real gap was missing multi-memory (capped the WebKit lane — the CABI routinely needs >1 memory per module), not JSPI. Resolved in WebKit trunk: default-on from the webkit-2342 playwright roll, lane at effective parity there (#11) |
| Testing wasmtime-with-wasmtime blind spots | medium | official suite + definitions.py ports as independent checks; consumer suites as real-workload gates |
Testing-toolchain format skew: testgen assembles with wast 255 while the shim validates with wasmparser 0.252 (wasmtime-47 pin) — the 0.253–0.255 window re-arited 🧵 thread opcodes (byte-level desync) |
low, bounded | known 5-entry xfail set; exits on the wasmtime bump (#1); testgen cannot downgrade (suite text syntax needs wast ≥255) |
| CSP variance in embedders | low | baseline needs only wasm-unsafe-eval — an invariant, no path may require full unsafe-eval (§3); specialized JS is emission-only, deploy-time or server-side cache import (#8) |
| Consumer coupling churn: 7+ downstream repos tracking pre-1.0 plan/contract formats | medium | caret-honest registry releases since 0.1.0 (#16, 2026-08-16): still 0.x/unstable, compatible within a minor line, breaking changes bump the minor — consumers couple by caret; pre-<shorthash> prerelease artifacts (exact pins) and git refs track main between releases; strict formatVersion equality already fails loud; consumer matrices as release gate (the wasmtime↔embedder relationship) |
| Consumer scope creep pulling WASI implementations into the core | medium | the wasi package is a separate deliverable with consumer-driven scope; §2 non-goal stands; the L3 runner belongs in polymorph-test (#14) |
| Host-boundary perf vs jco's generated JS (v1 interpreter) | low-medium | translation throughput measured (multi-MB components in tens of ms); cutover benches tracked with #8; iroh's polling-workaround removal dominates first-consumer numbers regardless |