Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion contracts/intrinsics.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ the plan (`CoreDef::Trampoline` / `lower-import`). Producers of the
requirement: the translator shim (per-plan manifest). Implementor: the runtime
(`runtime/src/intrinsics/`).

Status: **v0.2** (amended post-M0 and post-M1 — see amendment sections).
Status: **v0.3** (amended post-M0, post-M1, and at the CM#705 pin advance —
see amendment sections).

Sources of truth (pinned `wasmtime-environ 47.0.3`):
- (A) `wasmtime_environ::fact::Import` — every import FACT can emit.
Expand Down Expand Up @@ -148,6 +149,26 @@ core" is a feature, not a crash.
trampolines for exports the embedder never calls — deliberate
silent-acceptance tradeoff, not adopted without discussion.

## v0.3 amendments (CM#705 adoption, 2026-08-30)

1. **The reentrance-gate portion of ground rule 3 is withdrawn**
([#173](https://github.com/polymorph-components/polyengine/issues/173);
submodule pin `2f13265`). CM#705 removed `may_enter`, `entering_set`,
and the `enter_from`/`leave_to` bracket from the reference: no intrinsic
or trampoline checks or takes a reentrance gate anymore, and reentrance
into a live instance (host-mediated, dtor, `*-start-call`,
`enter-sync-call`) is valid. What entry sites still enforce is
**per-instance poisoning refusal** — a docs/architecture.md §6 named
divergence, not a reference rule: a trapped instance's corpse refuses
entry permanently with the recorded cause
([#145](https://github.com/polymorph-components/polyengine/issues/145)),
with the same-instance exemption preserved for dtor self-drops.
`may_leave`/flags-global behavior (v0.1 amendment 2) is unchanged;
"`may_enter` is host-only state" there is historical — the state no
longer exists. The `ComponentInstanceState` model fields
(`mayEnter`, `parent`, the synthetic root) remain defined but inert
pending the plan-format amendment that deletes them.

## JSPI integration constraints (M2 phase 3, empirically derived)

Every one of these is pinned by a test under `runtime/tests/jspi/`; they
Expand Down
87 changes: 50 additions & 37 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ contracts throughout the repo. Related documents:
(a) something external forces it — the official suite's `assert_trap`
matches 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,
Behavior mandated by the spec/reference (e.g. the borrow-lending traps,
per definitions.py) is spec conformance, not wasmtime-matching, even when
wasmtime exhibits it too. The tie-breaking authority for semantic
wasmtime exhibits it too. (Instance poisoning on trap was this until
CM#705 deleted the `may_enter` bracket it fell out of; per-instance
poisoning is now a named divergence, §6.) 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): where `definitions.py` contradicts the
Expand Down Expand Up @@ -271,9 +273,13 @@ Platform-neutral core (dependencies: `WebAssembly` JS API, `TextEncoder`/
3. Resource machinery: slab handle tables, own/borrow tracking (`num_lends`,
borrow invalidation at call return), dtor invocation (§7), FACT intrinsic
implementations.
4. Reentrance gates: `may_enter`/`may_leave` enforcement — **JSPI happily
permits reentry that the Component Model forbids**; the gates are ours to
enforce and must hold while suspended.
4. Instance-state rules: `may_leave` enforcement and poisoned-instance
refusal — **JSPI enforces no Component Model invariant for us**; the
state discipline is ours and must hold while suspended. (The `may_enter`
reentrance gate lived here until CM#705 removed it from the spec;
adopted at the 2f13265 pin advance, #173 — reentrance into a live
instance is valid, and the only entry refusal left is the
poisoned-corpse divergence, §6.)
5. 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
Expand Down Expand Up @@ -401,33 +407,39 @@ 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](https://github.com/polymorph-components/polyengine/issues/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](https://github.com/polymorph-components/polyengine/issues/99)/
[#101](https://github.com/polymorph-components/polyengine/issues/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
Resolved divergence (2026-08-20 → 2026-08-30,
[#165](https://github.com/polymorph-components/polyengine/issues/165)):
**`enter-sync-call` checked the callee's reentrance gate but did not take
it** — host-mediated reentry into an in-flight callee was admitted where
the then-pinned reference trapped. Accepted at the time on wasmtime parity
+ architecture (an enter/leave bracket spanning suspension points would
reintroduce the await-spanning-lock class
[#156](https://github.com/polymorph-components/polyengine/issues/156)/[#160](https://github.com/polymorph-components/polyengine/issues/160)
eliminated; and **upstream trajectory** — CM PR
[#705](https://github.com/WebAssembly/component-model/pull/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](https://github.com/polymorph-components/polyengine/issues/173). Until that advance the
pinned definitions.py remains the tie-breaker everywhere else — every
reentrance check polyengine does enforce stays in force.
eliminated) + upstream trajectory, and predicted to self-resolve at the
CM#705 pin advance. It did: at pin `2f13265` the reference deletes
`may_enter`/`entering_set`/`enter_from`/`leave_to` outright
([CM#705](https://github.com/WebAssembly/component-model/pull/705)), the
runtime's transient gates and brackets were removed with it
([#173](https://github.com/polymorph-components/polyengine/issues/173)),
and reentrance into a live instance — host-mediated or otherwise — is
simply valid. What `enter-sync-call` (and every other entry site) still
checks is the poisoned-corpse refusal below.

Named divergence (2026-08-30, [#173](https://github.com/polymorph-components/polyengine/issues/173),
formerly spec-derived): **per-instance poisoning is polyengine's only entry
refusal.** A trap that escapes a guest activation marks the instance's
corpse (`poisonedInstances`, re-keyed onto the marker in #251); every entry
site refuses a marked instance permanently, naming the original trap
([#145](https://github.com/polymorph-components/polyengine/issues/145)).
Pre-#705 this behavior fell out of the reference's broken enter/leave
bracket (`may_enter` stuck false); post-#705 the reference has no
instance-level trap state at all and wasmtime kills the whole store on
trap, so per-instance corpse semantics — sibling instances of the same
instantiation stay usable — is now purely this runtime's choice, pinned by
`builtin-trap-poisons-instance.wast`'s substring expectations and the
runtime poisoning suites. The same-instance exemption (`caller === callee`
passes vacuously, matching the old empty `entering_set`) is preserved in
the refusal guard for the dtor self-drop path.

## 7. Canonical ABI decisions

Expand Down Expand Up @@ -469,12 +481,13 @@ decide deliberately and document here.
- **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's `Store.lift` bracket,
reconstructed at `runtime/src/cabi/handles.ts` `callDtorGated` —
implemented at [#85](https://github.com/polymorph-components/polyengine/issues/85); the
same-instance exemption falls out of `entering_set`, not a special case).
destructor may spawn a cooperative thread that does."* Dtor entry into a
live instance is valid (CM#705, adopted at pin `2f13265`, #173 — the old
`may_enter_from` check is gone); a poisoned implementing instance still
refuses (§6 divergence, with the same-instance exemption preserved for
self-drops), and a trapping dtor poisons the **implementing** instance
(`runtime/src/cabi/handles.ts` `callDtorGated`, implemented at
[#85](https://github.com/polymorph-components/polyengine/issues/85)).
Host policy:
- CM-level blocking in a dtor → deterministic trap (falls out of general
sync-task rules).
Expand Down
67 changes: 30 additions & 37 deletions runtime/src/cabi/handles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,14 @@ export function canonResourceNew(
}

/**
* The reentrance-gating half of `ComponentInstance` that a dtor call needs.
* `ResourceTypeInfo.impl` is typed as the deliberately-minimal `InstanceLike`
* (cabi must not depend on task/), so the gate is reached structurally; the
* concrete implementor is `task/mod.ts` `ComponentInstanceState`.
* The slice of `ComponentInstance` a dtor call needs to identify a real
* component instance (as opposed to an imported/host resource, which has no
* instance at all). `ResourceTypeInfo.impl` is typed as the
* deliberately-minimal `InstanceLike` (cabi must not depend on task/), so it
* is recognised structurally; the concrete implementor is `task/mod.ts`
* `ComponentInstanceState`. The reentrance members are inert since CM#705
* (polyengine#173) and are matched only as the structural discriminator,
* pending the contract amendment that deletes the model.
*/
interface ReentranceGate {
mayEnterFrom(caller: unknown): boolean;
Expand All @@ -189,11 +193,8 @@ function isThenable(v: unknown): v is PromiseLike<unknown> {
}

/**
* Invoke a resource destructor through the reference's entry bracket.
*
* definitions.py `canon_resource_drop` (line 2319) does not call `rt.dtor`
* directly. It builds the dtor into a function instance and calls it through
* `Store.lift` / `Store.lower` (lines 2330-2333):
* Invoke a resource destructor, as definitions.py `canon_resource_drop`
* (@ 2f13265) does — through `Store.lift`/`Store.lower`:
*
* ```python
* dtor = rt.dtor or (lambda rep: [])
Expand All @@ -202,26 +203,23 @@ function isThenable(v: unknown): v is PromiseLike<unknown> {
* caller([h.rep])
* ```
*
* so the dtor inherits `Store.lift`'s gate verbatim (lines 579-584):
* `trap_if(not inst.may_enter_from(caller))`, `enter_from(caller)`, the call,
* then `leave_to(caller)` — which a trap skips, leaving the *implementing*
* instance permanently unenterable (poisoned).
*
* Two consequences that are easy to get wrong, both taken from the reference
* rather than from intuition:
* Post-CM#705 that lift carries NO gate: dropping a handle whose implementing
* instance is mid-execution is VALID, including the dtor-less case. The
* pre-#705 `may_enter_from`/`enter_from`/`leave_to` bracket (and with it the
* "same-instance exemption" that fell out of an empty entering set) is gone
* from the reference and gone from here.
*
* - the bracket runs even when `rt.dtor is None` (the `or (lambda rep: [])`
* above), so a dtor-less resource whose impl instance is mid-execution is
* still a trap. `may_enter_from`/`enter_from` walk `entering_set(caller)`
* (line 230), which is empty when the caller *is* the implementing
* instance — that, not a special case, is the same-instance exemption:
* a component dropping a handle to its own resource never traps.
* - poisoning applies to `rt.impl`, not to the dropping instance. The
* dropper's own bracket (its `Store.lift` frame) is broken by the same
* propagating trap at its own level; here only the callee is retired.
* What remains is polyengine's per-instance poisoning divergence, and it
* applies to `rt.impl`, not to the dropping instance: a trap out of the dtor
* buries the implementing instance (refusal names the original trap,
* polyengine#145; its live stream/future ends are retired, #66). The
* dropper is poisoned, if at all, by the same trap propagating at its own
* level. `entryRefusal`'s `caller !== callee` guard keeps a component
* dropping a handle to its OWN resource admissible even against a marked
* instance.
*
* Capability signals (`NeedsJspi`, `PendingCapability`) are not traps — see
* `isCapabilitySignal` in exec/boundary.ts — so they release the gate.
* `isCapabilitySignal` in exec/boundary.ts — so they do not poison.
*
* SCOPE (#160): this is the **guest-initiated** path only. A guest-initiated
* drop must complete synchronously (the reference lifts the dtor with
Expand Down Expand Up @@ -270,18 +268,14 @@ export function callDtorGated(
);
if (refusal !== null) trap(refusal);
}
impl.enterFrom(callerInst);

const poison = (e: unknown): void => {
if (e instanceof NeedsJspi || e instanceof PendingCapability) {
// Not a trap: the reference reaches `leave_to` on every execution these
// stand in for, so the instance stays enterable.
impl.leaveTo(callerInst);
return;
}
// `leave_to` is NOT reached (the gate stays taken, permanently), and the
// poisoned instance's live stream/future ends are retired (#66) through
// the same seam fact_calls.ts uses for its bracket-break sites.
// Capability signals are not traps: the operation they stand in for
// completes normally in the reference, so the instance stays healthy.
if (e instanceof NeedsJspi || e instanceof PendingCapability) return;
// A real trap buries the implementing instance, and its live
// stream/future ends are retired (#66) through the same seam
// fact_calls.ts uses for its poisoning sites.
notifyInstancePoisoned(impl, e);
};

Expand All @@ -302,7 +296,6 @@ export function callDtorGated(
poison(e);
throw e;
}
impl.leaveTo(callerInst);
}

export function canonResourceDrop(
Expand Down
Loading
Loading