From e4f99ade90a0865bc84c9cc7c9ff2df3d0b540ce Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Sun, 30 Aug 2026 18:54:48 -0400 Subject: [PATCH] task core: remove the may_enter reentrance gates (CM#705 adoption, #173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned reference (definitions.py @ 2f13265) no longer has may_enter, entering_set, enter_from, leave_to, or ComponentInstance.parent: Store.lift runs canon_lift with no gate, Store.tick resumes any ready thread with no bracket, and reentrance into a live instance is valid. This PR removes every runtime call into that model — the host-entry gate, the FACT sync/async start-call brackets, enter-sync-call's transient check, the dtor entry bracket, resumeWith's bracket and backstop assert, tick's bracket and enterability filter, and requestCancellation's delivery gate — plus entryRefusal's transient clause (#251's marker re-key made this a pure subtraction). What survives is the named divergence: per-instance poisoning. A trap still marks the instance's corpse (notifyInstancePoisoned; capability signals excepted) and every entry site refuses a marked instance with the recorded cause (#145); the '(reentrance forbidden)' qualifier is dropped from the host-entry message (the suite matches by substring). The model definitions and the #155/#156/#161 deferral machinery stay in place inert - a follow-up deletes them with the plan-format amendment. Corpus: zero xfail changes by design - FACT-47 statically masks every row this would flip (#248); builtin-trap-poisons-instance stays green via the marker. Runtime suites carry the proof: the synthetic-root/deferral pins invert to merged semantics, and the headline pin is new - a host import synchronously re-entering its own live instance, both activations completing (e2e_imports_test.ts). Docs/contracts (orchestrator): intrinsics.md v0.3 amendment withdraws the reentrance-gate rule; architecture.md closes #165's divergence entry as resolved-by-adoption, names per-instance poisoning as the sole entry refusal, and updates the JSPI enforcement and dtor sections. --- contracts/intrinsics.md | 23 +- docs/architecture.md | 87 +++++--- runtime/src/cabi/handles.ts | 67 +++--- runtime/src/exec/boundary.ts | 129 +++++------ runtime/src/intrinsics/fact_calls.ts | 48 ++-- runtime/src/intrinsics/mod.ts | 77 ++----- runtime/src/task/mod.ts | 58 +++-- runtime/src/task/scheduler.ts | 159 +++++-------- runtime/src/task/streams.ts | 33 +-- runtime/src/task/thread.ts | 63 ++---- runtime/tests/cancel_bracket_race_test.ts | 74 +++--- runtime/tests/dtor_normalization_test.ts | 58 +++-- .../tests/enter_sync_call_reentrance_test.ts | 100 +++++---- runtime/tests/integration/e2e_hello_test.ts | 31 ++- runtime/tests/integration/e2e_imports_test.ts | 57 ++++- runtime/tests/integration/e2e_suite_test.ts | 15 +- runtime/tests/jspi/bridge_test.ts | 26 +-- runtime/tests/jspi/hop_atomicity_test.ts | 13 +- runtime/tests/poison_cause_test.ts | 27 ++- runtime/tests/realloc_may_leave_test.ts | 7 +- runtime/tests/resource_lender_unwind_test.ts | 28 +-- runtime/tests/resource_lifetime_test.ts | 113 +++++----- runtime/tests/settled_deferral_test.ts | 45 ++-- runtime/tests/streams_teardown_test.ts | 59 +++-- runtime/tests/task_test.ts | 210 ++++++++---------- 25 files changed, 762 insertions(+), 845 deletions(-) diff --git a/contracts/intrinsics.md b/contracts/intrinsics.md index f405a36..7950f99 100644 --- a/contracts/intrinsics.md +++ b/contracts/intrinsics.md @@ -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. @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 32530d4..b124f36 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 @@ -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 @@ -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 @@ -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). diff --git a/runtime/src/cabi/handles.ts b/runtime/src/cabi/handles.ts index 8205d26..320ea15 100644 --- a/runtime/src/cabi/handles.ts +++ b/runtime/src/cabi/handles.ts @@ -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; @@ -189,11 +193,8 @@ function isThenable(v: unknown): v is PromiseLike { } /** - * 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: []) @@ -202,26 +203,23 @@ function isThenable(v: unknown): v is PromiseLike { * 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 @@ -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); }; @@ -302,7 +296,6 @@ export function callDtorGated( poison(e); throw e; } - impl.leaveTo(callerInst); } export function canonResourceDrop( diff --git a/runtime/src/exec/boundary.ts b/runtime/src/exec/boundary.ts index 8e6ee48..05513ba 100644 --- a/runtime/src/exec/boundary.ts +++ b/runtime/src/exec/boundary.ts @@ -1079,7 +1079,13 @@ async function driveAsync( ); if (parked.length === 0) { // Every awaiting thread's settle is deferred on a non-enterable - // instance. The way out is the lock holder finishing, and the only + // instance. INERT since CM#705 (polyengine#173): nothing is ever + // non-enterable now, so `dispatchableTail` never defers and this + // branch is unreachable by construction rather than by argument. Kept + // textually intact pending the contract amendment that deletes the + // reentrance model. + // + // The way out is the lock holder finishing, and the only // await-spanning host-entry lock is the async-dtor bracket, which // registers in `pendingHostCalls` — so park on those, plus the // driver-arrival one-shot: every park in this loop races it, so the @@ -1372,24 +1378,19 @@ export function createLiftedFunction(input: { // Depth of the sync-call scope stack on entry; see the `finally` below. const syncCallDepth = syncCallStack?.length ?? 0; - // Reference `Store.lift` (line 578): the host is the caller, so the - // entering set is the callee's `self_and_ancestors()`. - // On refusal, distinguish the corpse from the crowd: a poisoned - // instance's refusal names the original trap (polyengine#145 ask 1). + // Reference `Store.lift` (@ 2f13265) runs `canon_lift` with NO gate: the + // transient reentrance check went away with CM#705, so host entry into a + // live instance is valid. What survives is polyengine's per-instance + // poisoning divergence — a poisoned instance is a corpse, and its refusal + // names the original trap (polyengine#145 ask 1). { const refusal = entryRefusal( inst, null, - `cannot enter component instance ${inst.index} (reentrance forbidden)`, + `cannot enter component instance ${inst.index}`, ); if (refusal !== null) trap(refusal); } - // The set this entry locked (definitions.py `ComponentInstance.enter_from` - // iterates `entering_set`). Remembered so a trap can leave exactly these - // locked and no others. - const enteredSet = inst.enteringSet(null); - inst.enterFrom(null); - let entered = true; let completed = false; let resolved: ComponentValue[] | null = null; @@ -1472,45 +1473,36 @@ export function createLiftedFunction(input: { // definition; assert that resting state rather than leaving the // component bricked. // - // The *entered* instances are excluded: they are poisoned by this trap - // (see `poison` below) and must stay exactly as the trap left them. - // Restoring their `may_leave` would be tidying the state of an instance - // that is no longer allowed to run at all. + // The ENTERED instance is excluded: it is poisoned by this trap (see + // `poison` below) and must stay exactly as the trap left it. Restoring + // its `may_leave` would be tidying the state of an instance that is no + // longer allowed to run at all. for (const i of allInstances?.() ?? []) { - if (!enteredSet.has(i as unknown as ComponentInstanceState)) { + if (i as unknown as ComponentInstanceState !== inst) { i.mayLeave = true; } } }; - const leave = (): void => { - if (!entered) return; - entered = false; - inst.leaveTo(null); - }; - /** - * A trap escaped the task: **do not** release the reentrance lock. - * - * definitions.py `Store.lift` (line 578) is + * A trap escaped the task: mark the instance poisoned. * - * ```python - * trap_if(not inst.may_enter_from(caller)) - * inst.enter_from(caller) - * on_cancel = canon_lift(...) # <-- a Trap propagates out of here - * inst.leave_to(caller) # <-- and so this never runs - * ``` + * polyengine's NAMED DIVERGENCE. definitions.py has no notion of a + * post-trap instance at all — a Trap is the end of the world — and + * wasmtime's answer is to poison the whole store. This runtime keeps the + * component graph alive and buries only the instance that trapped: it is + * not in a known state, so it may never be entered again, and the next + * call reports `cannot enter component instance` with the recorded cause + * appended (polyengine#145 ask 1). + * `test/async/builtin-trap-poisons-instance.wast` asserts exactly this, + * twice; the marker (`notifyInstancePoisoned`) is the whole mechanism + * since #251's re-key, and since CM#705 removed `may_enter` it is also + * the only one there could be. * - * so a trapping task leaves every instance it entered with - * `may_enter == False` permanently. That is the Component Model's - * "poisoning": a component that trapped is not in a known state, so it may - * never be entered again, and the next call reports `cannot enter - * component instance`. `test/async/builtin-trap-poisons-instance.wast` - * asserts exactly this, twice. - * - * Only the entered set is affected; sibling instances stay usable, which - * is why the lock is released per-instance rather than by poisoning a - * whole store the way wasmtime does. + * Only `inst` is affected; sibling instances stay usable. (Historically + * this walked the entry's `entering_set` and had to hand-release the + * synthetic per-instantiation root to avoid store-wide poisoning; with + * the gate gone there is no set and no root to release.) * * Poisoned instances can never rendezvous again, so their handle tables' * live stream/future ends are retired here (#66): parked host operations @@ -1518,23 +1510,13 @@ export function createLiftedFunction(input: { * lets the embedder layer reject them loudly. */ const poison = (e: unknown): void => { - entered = false; // consumed: the lock is now permanent - // ...for the leaf. The synthetic per-instantiation root (plan v3 - // amendment 4) is in `enteredSet` too, and leaving IT locked would - // poison every instance of the component — exactly the store-wide - // behaviour the paragraph above says this runtime deliberately does not - // have. Released; see `releaseSyntheticRootOnPoison` in task/mod.ts. - inst.releaseSyntheticRootOnPoison(); // Through the seam (not retireInstanceAsyncEnds directly) so the // poison marker is recorded too — `Thread.resumeWith` retires this // instance's late settles against it instead of assert-cascading. - for (const i of enteredSet) { - if (i.isSyntheticRoot) continue; - notifyInstancePoisoned( - i as unknown as { handles: Iterable }, - e, - ); - } + notifyInstancePoisoned( + inst as unknown as { handles: Iterable }, + e, + ); }; /** @@ -1544,9 +1526,9 @@ export function createLiftedFunction(input: { * not "the component faulted". Poisoning on them is wrong on the * reference's own terms: the operation they stand in for — a synchronous * stream copy, `waitable-set.wait`, a blocking cross-component call — - * *blocks and then completes* in definitions.py. `Store.lift` reaches - * `leave_to` in every one of those executions, so the instance stays - * enterable. Poisoning would attribute a permanent fault to a component + * *blocks and then completes* in definitions.py. Every one of those + * executions returns normally there, so the instance stays healthy. + * Poisoning would attribute a permanent fault to a component * that, on a complete runtime, is perfectly healthy — and it cascades: * one unsupported operation made every later call on that instance report * `cannot enter component instance`, which is neither our real behaviour @@ -1554,8 +1536,6 @@ export function createLiftedFunction(input: { * * What unwinding must still do on this path, and what it must not: * - * - MUST release the reentrance lock (`leave`) — the call is over and no - * activation of this instance survives it. * - MUST unwind the FACT sync-call scopes and restore `may_leave` * (`unwind`), for exactly the reasons it does after a trap: a bail-out * mid-adapter skips `exit-sync-call` and the `may_leave` restore, and @@ -1600,15 +1580,9 @@ export function createLiftedFunction(input: { } } catch (e) { unwind(); - if (isCapabilitySignal(e)) leave(); - else poison(e); + if (!isCapabilitySignal(e)) poison(e); throw e; } - // The reentrance gate is released here, before the store is pumped: - // `Store.tick` re-enters each waiting thread's instance itself - // (`enter_from(None)` / `leave_to(None)`), exactly as in the reference, - // where `lift_and_run` ticks after `store.invoke` has returned. - leave(); let pending: void | Promise; try { @@ -1676,11 +1650,10 @@ export function createLiftedFunction(input: { // // A promising-wrapped entry settles a microtask AFTER the guest's core // call returns, even when nothing suspended (jspi pin (j)) — so there - // is a hop between core return and the host-side result LIFT, and the - // reentrance bracket has already been released by then (`leave()` runs - // when the first segment parks). In the reference no such window - // exists: `canon_lift` for sync options runs core + lift atomically - // inside one entered bracket. Admitting another host call into the + // is a hop between core return and the host-side result LIFT, with + // nothing holding the instance against another host entry. In the + // reference no such window exists: `canon_lift` for sync options runs + // core + lift atomically. Admitting another host call into the // window lets a full guest turn mutate the memory the pending lift // will read — observed as `Trap: list too long` lifting the wosh // engine's `tick` (`list>`) after a concurrent `feed-keys` @@ -1813,9 +1786,11 @@ function dtorOptions(instance: ComponentInstanceState): ResolvedOptions { * → `PendingCapability`, or a foreign-task misattribution, the #24 class). * * Under the lift harness all three go away structurally: the activation has a - * real `Task` + implicit `Thread`, the entry bracket is released when the - * first segment parks (`leave()` before `drive`), and settled tails flow - * through `serviceSettled` like any other lifted sync call. + * real `Task` + implicit `Thread`, and settled tails flow through + * `serviceSettled` like any other lifted sync call. (CM#705 has since removed + * the transient gate entirely, so the first two defects could no longer arise + * at all; the history is kept because the Task/Thread shape it forced is + * still what makes built-ins inside a dtor well-attributed.) * * The returned function takes the rep and returns either `undefined` (the * activation completed synchronously — the overwhelmingly common case) or a @@ -1827,7 +1802,7 @@ export function createDtorEntry(input: { /** * The destructor's core function, unwrapped: `createLiftedFunction` applies * `enterWasm` itself per `suspensionMode`. `null` is the reference's - * `rt.dtor or (lambda rep: [])` — the bracket still runs. + * `rt.dtor or (lambda rep: [])` — the lift still runs. */ dtor: CoreFn | null; /** `rt.impl`, the implementing instance the lift enters. */ diff --git a/runtime/src/intrinsics/fact_calls.ts b/runtime/src/intrinsics/fact_calls.ts index f886eb2..20ab3fd 100644 --- a/runtime/src/intrinsics/fact_calls.ts +++ b/runtime/src/intrinsics/fact_calls.ts @@ -60,11 +60,12 @@ // * Reentrance between *related* instances is resolved statically: // `trampoline.rs:116-127` emits an unconditional // `trap(Trap::CannotEnterComponent)` when the lower and lift instances are -// the same or are ancestors of one another. So the flat-instance-tree gap -// recorded in task/mod.ts is NOT load-bearing here — wasmtime has already -// decided those cases at translation time, and the remaining runtime check -// is the ordinary "is the callee instance currently executing" one, which -// a flat tree answers correctly. +// the same or are ancestors of one another. The runtime-side counterpart +// — "is the callee instance currently executing" — is GONE as of CM#705 +// (definitions.py @ 2f13265 has no `may_enter`), so the only refusal left +// at these call sites is polyengine's per-instance poisoned-corpse check +// (`entryRefusal`). The flat-instance-tree gap recorded in task/mod.ts is +// doubly not load-bearing here. import { assert_, trap } from "../cabi/trap.ts"; import { MAX_FLAT_RESULTS } from "../cabi/mod.ts"; @@ -655,9 +656,9 @@ export function createSyncStartCall( lenderScope, }); - // Reference `Store.lift`: the reentrance gate, with the *caller* as the - // entering context (definitions.py `entering_set(caller)`). - // A poisoned callee's refusal names the original trap (polyengine#145). + // Reference `Store.lift` (@ 2f13265) has no reentrance gate; the only + // refusal left is polyengine's poisoned-corpse check, whose message names + // the original trap (polyengine#145). { const refusal = entryRefusal( prepared.calleeInst, @@ -666,23 +667,16 @@ export function createSyncStartCall( ); if (refusal !== null) trap(refusal); } - prepared.calleeInst.enterFrom(prepared.callerInst); - let ok = false; try { const thread = spawn(task, body); thread.resume(); - ok = true; } catch (e) { - // A trap leaves the instance poisoned: `leave_to` is not reached - // (definitions.py `Store.lift`, line 578). A *capability signal* does - // not — see the `isCapabilitySignal` note in exec/boundary.ts. - if (e instanceof NeedsJspi || e instanceof PendingCapability) { - prepared.calleeInst.leaveTo(prepared.callerInst); - } else { - // Retire the poisoned CALLEE's stream/future ends (#66): this is a - // bracket-break site like `Store.tick`'s, and the trap unwinds to a - // hooked site that walks only the CALLER's chain — a composed - // component's callee would otherwise strand its host peers. + // A trap poisons the callee instance. A *capability signal* does not — + // see the `isCapabilitySignal` note in exec/boundary.ts. + if (!(e instanceof NeedsJspi) && !(e instanceof PendingCapability)) { + // Retire the poisoned CALLEE's stream/future ends (#66): the trap + // unwinds to a hooked site that walks only the CALLER's chain — a + // composed component's callee would otherwise strand its host peers. notifyInstancePoisoned(prepared.calleeInst, e); } // The lent handles are the CALLER's, and the caller is not poisoned by @@ -696,7 +690,6 @@ export function createSyncStartCall( lenderScope.releaseLenders(); throw e; } - if (ok) prepared.calleeInst.leaveTo(prepared.callerInst); if (callerResults === null) { // The callee did not resolve within its first activation. definitions.py @@ -912,20 +905,14 @@ export function createAsyncStartCall( ); if (refusal !== null) trap(refusal); } - prepared.calleeInst.enterFrom(prepared.callerInst); - let ok = false; let thread: Thread; try { thread = spawn(task, body); thread.resume(); - ok = true; } catch (e) { // See the sync form above and `isCapabilitySignal` in exec/boundary.ts. - if (e instanceof NeedsJspi || e instanceof PendingCapability) { - prepared.calleeInst.leaveTo(prepared.callerInst); - } else { - // Bracket-break site — retire the poisoned callee's ends (#66), - // as in the sync form above. + if (!(e instanceof NeedsJspi) && !(e instanceof PendingCapability)) { + // Retire the poisoned callee's ends (#66), as in the sync form above. notifyInstancePoisoned(prepared.calleeInst, e); } // The subtask never reached `report()`, so it has no handle in the @@ -938,7 +925,6 @@ export function createAsyncStartCall( unwindSubtaskLenders(subtask); throw e; } - if (ok) prepared.calleeInst.leaveTo(prepared.callerInst); const report = (): CoreValue => { if (subtask.resolved()) { diff --git a/runtime/src/intrinsics/mod.ts b/runtime/src/intrinsics/mod.ts index 108fd77..136f190 100644 --- a/runtime/src/intrinsics/mod.ts +++ b/runtime/src/intrinsics/mod.ts @@ -470,72 +470,35 @@ function createTrampolineBody( async_?: number, calleeInstance?: number, ) => { - // Reference reentrance gate. Every guest->guest call in - // definitions.py routes through the callee's lift wrapper: - // canon_lower (line 2312) calls - // `callee(on_start, on_resolve, caller = thread.task.inst)`, - // and `callee` is `Store.lift`'s `func_inst` (lines 578-585), whose - // first act is - // `trap_if(not inst.may_enter_from(caller))` (line 581) - // with `entering_set(caller) = callee.self_and_ancestors() - // - caller.self_and_ancestors()` - // (lines 230-234). - // A sync fused adapter is an *optimization* of that path, so the gate - // belongs here (issue #99). + // ENTRY REFUSAL at the fused sync-call boundary. // - // Note on the shape of the entering set, which is what makes this - // check safe for the legal shapes: - // * caller == callee, or either an ancestor of the other -> the - // entering set is empty and this never traps. Those pairs never - // reach this trampoline anyway: FACT emits an unconditional - // `CannotEnterComponent` trap for them at compile time - // (wasmtime-environ 47.0.3 `fact/trampoline.rs:120-127`), which - // is what `test/async/trap-on-reenter.wast` cases 2 and 3 pin. - // * an *idle* sibling -> `mayEnter` is true, no trap. This is what - // `test/async/sync-barges-in.wast` needs: an async callee that is - // merely blocked has already run `leave_to` (its `canon_lift` - // returned), so a sync sibling may barge in. - // * an *entered* sibling -> trap, which is the A -> C -> A cycle. + // The reference's reentrance gate is GONE (CM#705; definitions.py @ + // 2f13265 has no `may_enter`/`entering_set`/`enter_from`): a + // guest->guest call through `Store.lift` now runs `canon_lift` + // unconditionally, and host-mediated reentrance — host -> A.f -> C.g + // -> host import -> host invokes C.g — is simply valid. wasmtime's + // fused adapters agreed all along: `enter_guest_sync_call` + // (47.0.3 `runtime/component/concurrent.rs:1723`) performs no + // reentrance check, and `fact/trampoline.rs:120-127` decides the + // caller==callee / ancestor pairs statically at compile time (what + // `test/async/trap-on-reenter.wast` cases 2 and 3 pin — a translation + // -time trap, not this site). // - // CONTRACT / reachability: a pure guest-to-guest sibling cycle is - // unreachable by construction, because component instance imports - // form a DAG (a callee must be instantiated before its caller, so it - // cannot hold an import of its caller; `wasm-tools` rejects the - // mutual-import composition outright). wasmtime relies on exactly - // that to elide the runtime check in fused adapters -- see the - // comment in `may_enter`, wasmtime 47.0.3 - // `runtime/component/concurrent.rs:1876-1886`, and - // `enter_guest_sync_call` (concurrent.rs:1723) which performs no - // reentrance check at all. The gate is kept anyway because the - // reference mandates it and no corpus test pins the permissive - // behaviour; it is cheap, and it is the honest place for the - // invariant to be asserted rather than assumed. + // polyengine#165 recorded the omitted enter/leave bracket as a named + // divergence "pending the pin advance". The pin advance happened + // (polyengine#173): the divergence is CLOSED, because there is no + // bracket left to omit. // - // Deliberately *not* done here: `enter_from` / `leave_to` around the - // bracket. The reference locks the callee for the duration, which - // would additionally trap host-mediated reentrance (host -> A.f -> - // C.g -> host import -> host invokes C.g). - // - // ADJUDICATED 2026-08-20 (polyengine#165; named divergence in - // docs/architecture.md section 6): accepted — the bracket stays - // omitted. Three grounds: wasmtime parity (`enter_guest_sync_call` - // checks nothing); taking the bracket would create a guest-to-guest - // lock spanning suspension points, reintroducing the await-spanning - // -lock class removed by #156/#160; and upstream is deleting the - // trap outright — CM PR #705 ("CABI: remove the may_enter - // flag/trap") makes previously-trapping reentrance valid, so this - // divergence is a trailing indicator of the removal and - // self-resolves at the submodule pin advance (migration map: - // polyengine#173). Until that pin advance the reference's checks polyengine - // DOES enforce stay in force. + // What survives here is polyengine's per-instance poisoning: a + // callee that trapped is a corpse and may never be entered again, + // and the refusal names the original trap (polyengine#145). That is + // the whole content of this check. if ( typeof callerInstance === "number" && typeof calleeInstance === "number" ) { const callerInst = ctx.componentInstance(callerInstance >>> 0); const calleeInst = ctx.componentInstance(calleeInstance >>> 0); - // A poisoned callee's refusal names the original trap (polyengine#145). - // Check-only: no `enterFrom` here, so no bracket to break. const refusal = entryRefusal( calleeInst, callerInst, diff --git a/runtime/src/task/mod.ts b/runtime/src/task/mod.ts index 24a6719..fb35eac 100644 --- a/runtime/src/task/mod.ts +++ b/runtime/src/task/mod.ts @@ -453,12 +453,16 @@ export class Task { } /** - * definitions.py `Task.request_cancellation` (line 519). Delivered to a - * cancellable thread if one exists and the instance is enterable; otherwise - * recorded as pending, to be picked up at the next cancellable block point - * (`deliverPendingCancel`). + * definitions.py `Task.request_cancellation` (@ 2f13265). Delivered to a + * cancellable thread if one exists; otherwise recorded as pending, to be + * picked up at the next cancellable block point (`deliverPendingCancel`). + * + * `caller` is retained for the call-site shape (fact_calls.ts's + * `subtask.onCancel`) and for diagnostics; the reentrance condition it used + * to feed went away with CM#705 (polyengine#173). */ requestCancellation(caller: ComponentInstanceState | null): void { + void caller; if (this.state === "initial") { this.state = "cancel-delivered"; this.implicitThread!.resume(CANCELLED_TRUE); @@ -510,39 +514,30 @@ export class Task { } } } - // Delivery needs the callee instance to be enterable — and NOT poisoned. - // The marker is the authoritative poisoning input (polyengine#173): a - // poisoned instance leaves the cancellation pending. Behaviorally - // identical today (the marker locks the leaf's `mayEnter` forever), but - // it survives the CM#705 deletion of `may_enter`; the `caller !== inst` - // guard is `entering_set`'s vacuous pass, as in `entryRefusal`. - const poisoned = caller !== this.inst && isInstancePoisoned(this.inst); - if (candidates.length > 0 && !poisoned && this.inst.mayEnterFrom(caller)) { + // Merged reference (definitions.py @ 2f13265): `if candidates: deliver`, + // full stop — no enterability condition, no bracket (CM#705). + // + // ONE divergence conjunct survives: a POISONED instance is a corpse whose + // threads never resume, so the request parks as pending-cancel forever — + // which is the honest state, since a corpse can never reach a cancellable + // suspension to deliver at. The reference never faces this because a trap + // there kills the whole store. The marker is the authoritative input + // (polyengine#173, #251's re-key). + if (candidates.length > 0 && !isInstancePoisoned(this.inst)) { this.state = "cancel-delivered"; - this.inst.enterFrom(caller); try { chooseCandidate(candidates).resume(CANCELLED_TRUE); } catch (e) { - // Deliberately NOT a `finally`, mirroring `Store.tick`'s - // bracket-break discipline (scheduler.ts): the reference wraps the - // delivery `resume(Cancelled.TRUE)` in no handler at all - // (definitions.py `Task.request_cancellation`, lines 519-532; the - // delivery is line 531), so a Trap escaping it never reaches - // `leave_to` on line 532 — the entered set stays locked, i.e. the - // Component Model's instance poisoning. A `finally` here would - // un-poison a half-unwound callee. + // A trap escaping the delivery poisons the callee instance + // (polyengine#164/#212) — polyengine's per-instance corpse divergence; + // the reference wraps this `resume(Cancelled.TRUE)` in no handler at + // all and simply ends the world. // // Capability signals are the exception, exactly as in `tick`: they - // mark this RUNTIME incomplete, not the component faulted, and in - // the reference the blocking operation completes and `leave_to` IS - // reached. - if (e instanceof NeedsJspi || e instanceof PendingCapability) { - this.inst.leaveTo(caller); - } else { - // The synthetic root is released so the poisoning stays - // per-instance (plan v3 amendment 4); for a guest caller the - // entering set is the leaf alone and the release is a no-op. - this.inst.releaseSyntheticRootOnPoison(); + // mark this RUNTIME incomplete, not the component faulted, and in the + // reference the blocking operation they stand in for completes + // normally. + if (!(e instanceof NeedsJspi) && !(e instanceof PendingCapability)) { notifyInstancePoisoned( this.inst as unknown as { handles: Iterable }, e, @@ -550,7 +545,6 @@ export class Task { } throw e; } - this.inst.leaveTo(caller); } else { this.state = "pending-cancel"; } diff --git a/runtime/src/task/scheduler.ts b/runtime/src/task/scheduler.ts index 86573de..d72658a 100644 --- a/runtime/src/task/scheduler.ts +++ b/runtime/src/task/scheduler.ts @@ -194,12 +194,12 @@ export function instancePoisonCause(inst: object): unknown { /** * Append the recorded poison cause to an entry-refusal trap message - * (polyengine#145 ask 1). "cannot enter component instance" covers two states - * that send an embedder down entirely different debugging paths — a - * transient reentrance overlap (retry later, look for caller-side call - * overlap) and a permanently poisoned instance (the corpse of an earlier - * trap, which this suffix names). Only the poisoned case gets the suffix: - * the transient message stays byte-identical, and the suffix is + * (polyengine#145 ask 1). Since the transient gate went away with CM#705, + * "cannot enter component instance" has exactly one cause left — a + * permanently poisoned instance, the corpse of an earlier trap — and this + * suffix names the trap that made it one. The call is kept unconditional at + * the refusal sites (returning `base` unchanged for an unmarked instance) so + * the message construction stays in one place; the suffix is * conformance-safe because the official suite matches trap messages by * substring (harness/src/runner.ts). */ @@ -214,45 +214,36 @@ export function withPoisonCause(inst: object, base: string): string { * now, and if not, what does the refusal trap say? Returns `null` when entry * is allowed, otherwise the exact trap message for `base`. * - * THE POISONING RE-KEY (polyengine#173). Poisoning is decided by the POISON - * MARKER (`isInstancePoisoned`), not by `mayEnter`. Today the two agree — - * the marker is only ever set at a bracket-break site that simultaneously - * leaves the leaf's `mayEnter` false forever, and nothing restores it - * (`releaseSyntheticRootOnPoison` touches synthetic roots only, and roots are - * never marked) — so clause 1 implies clause 2 and the re-key is observably - * neutral. The point is what happens NEXT: CM PR #705 deletes `may_enter` - * entirely, and at that pin advance clause 2 is deleted wholesale while - * poisoning survives untouched, because it never depended on `may_enter`. + * POISONING IS THE WHOLE MECHANISM (polyengine#173, CM#705 adoption). The + * transient reentrance gate is GONE: at the pinned reference + * (definitions.py @ 2f13265) `may_enter`, `entering_set`, `enter_from`, + * `leave_to` and `ComponentInstance.parent` no longer exist — `Store.lift` + * runs `canon_lift` with no gate at all, so host-mediated reentrance into a + * live instance is simply VALID. The clause that consulted `mayEnterFrom` + * was deleted with the pin advance; #251's re-key onto the marker is what + * made that deletion a pure subtraction (the marker never depended on + * `may_enter`). * - * Byte identity with the pre-re-key `if (!X.mayEnterFrom(Y)) trap( - * withPoisonCause(X, BASE))` at every call site: - * - marked callee ⇒ clause 1 returns `withPoisonCause(callee, base)`, and - * clause 2 would have fired too (marker ⇒ leaf locked forever), yielding - * the same suffixed string; - * - unmarked and locked ⇒ clause 2 returns `base`, which is exactly what - * `withPoisonCause` returns for an unmarked instance; - * - unmarked and enterable ⇒ no trap, then as now. + * What survives is polyengine's NAMED DIVERGENCE: per-instance poisoning. A + * trapped instance is a corpse — entry is refused permanently, with the + * recorded cause appended (polyengine#145 ask 1) — where wasmtime instead + * kills the whole store. The reference never faces the question because a + * trap there is the end of the world. * - * The `caller !== callee` guard on clause 1 preserves the reference's - * vacuous pass on an EMPTY entering set (definitions.py `entering_set`, - * line 230: `self_and_ancestors() - caller.self_and_ancestors()`, empty when - * caller is callee). A dtor invoked from inside its own instance - * (cabi/handles.ts) is the live case: it must not be refused by its own - * instance's marker. With the synthetic-root shape (`{leaf, root}` ancestry, - * mod.ts `enteringSet`), "some member of `enteringSet(caller)` is marked" is - * exactly `caller !== callee && isInstancePoisoned(callee)`: the only other - * member a set can hold is the root, and roots are never marked. + * The `caller !== callee` guard preserves the reference's vacuous pass on an + * EMPTY entering set (the pre-#705 `entering_set` was + * `self_and_ancestors() - caller.self_and_ancestors()`, empty when caller is + * callee). A dtor invoked from inside its own instance (cabi/handles.ts) is + * the live case: it must not be refused by its own instance's marker. */ export function entryRefusal( - callee: { mayEnterFrom(caller: unknown): boolean }, + callee: object, caller: unknown, base: string, ): string | null { if (caller !== callee && isInstancePoisoned(callee)) { return withPoisonCause(callee, base); } - // CM#705 deletes `may_enter`; this clause goes with it (polyengine#173). - if (!callee.mayEnterFrom(caller)) return base; return null; } @@ -1029,7 +1020,12 @@ export class Store { * * A tail whose instance is NOT host-enterable is DEFERRED IN PLACE — left * in the queue, skipped here — until the lock releases (issue #156). - * `resumeWith` brackets the resumption with `enterFrom(null)`, and under + * NOTE (polyengine#173, CM#705): with the transient reentrance gate gone, + * `mayEnterFrom(null)` is constant-true and this deferral is INERT — every + * non-stale tail is dispatched immediately. The machinery is kept textually + * intact pending the contract amendment that deletes the model; the + * paragraphs below record why it existed. + * * `resumeWith` brackets the resumption with `enterFrom(null)`, and under * the shared synthetic per-instantiation root a host entry into ANY * instance of the graph locks the root, so while one instance is entered a * sibling's tail cannot be dispatched: dispatching it tripped @@ -1162,9 +1158,9 @@ export class Store { } /** - * definitions.py `Store.tick` (line 597): resume one ready thread, bracketed - * by the reentrance gate for a host-initiated entry (`enter_from(None)` / - * `leave_to(None)`). + * definitions.py `Store.tick` (@ 2f13265): resume one ready thread. Post + * CM#705 there is no bracket and no gate — the reference body is exactly + * "pick a ready thread, resume it". * * Returns false when no thread was ready, so callers can distinguish * "made progress" from "stuck" without inspecting the queue themselves. @@ -1188,80 +1184,46 @@ export class Store { // Same discipline, other edge: a settled-but-unserviced activation tail // (see `settled`) is mid-"atomic resume" from the reference's point of // view; scheduling anything before servicing it acts on phantom state. - // - // Only a SERVICEABLE tail gates: a tail DEFERRED on a non-enterable - // instance (issue #156) cannot be dispatched now, and gating on it would - // wedge the store (and hot-spin the drivers). It does not need to gate, - // because its instance is self-excluded from the candidate set by the - // enterability filter below — the same predicate on the same instance — - // so no thread of that instance can be resumed while its tail waits. + // That is settle-order discipline and has nothing to do with reentrance: + // it survives CM#705 unchanged. `hasServiceableSettled` (rather than + // "queue non-empty") only because a tail whose thread was already resumed + // elsewhere must not wedge the store. if (this.hasServiceableSettled()) return false; - // Ready is not sufficient: the thread's instance must also be enterable - // from the host. The reference *asserts* this in `Store.tick` — a waiting - // thread's instance is always re-enterable there, because its host entry - // has either left or is itself a waiting thread. That does not hold here. - // - // Instances of one linked graph share a Store and, with it, the synthetic - // per-instantiation root (plan v3 amendment 4): `enterFrom(null)` locks - // the callee AND the root, so while ANY instance is entered from the host - // — e.g. a sync export parked on an async host import, which in this - // runtime is a real suspension rather than a blocked OS thread — no - // instance in the graph is host-enterable. A sibling instance whose - // thread goes ready in that window (event-driven wakeups do this on every - // clock turn) would then trip the assertion, and the failure escapes - // through whatever host-import promise is in flight. + // Ready is sufficient — almost. The reentrance constraint that used to + // filter this set is GONE: post-CM#705 (definitions.py @ 2f13265) + // `Store.tick` resumes any ready thread with no gate and no bracket, so a + // sibling instance's thread going ready while another instance is entered + // from the host is simply resumable. // - // So "ready but not enterable" is treated as no progress, exactly as the - // sync driving loop already does by restricting its candidate set to the - // callee instance (`driveSyncLift` below; definitions.py `canon_lift`). - // This cannot livelock: the entered call's host import settles from host - // JS independently of `tick`, and when that call returns, `leaveTo(null)` - // unlocks the root and the skipped threads run on the next turn. - // A poisoned instance is excluded by the MARKER, not by its (permanently - // false) `mayEnter` — the poisoning re-key of polyengine#173. Identical - // behavior today; keyed so the CM#705 removal of `may_enter` deletes only - // the second conjunct. + // What remains is polyengine's per-instance poisoning divergence: a + // poisoned instance is a corpse, its threads must never resume, and the + // MARKER is the whole test (#251's re-key). `Thread.resumeWith` makes the + // same call on the tail path. const candidates = this.readyCandidates().filter((t) => - !isInstancePoisoned(t.task.inst) && t.task.inst.mayEnterFrom(null) + !isInstancePoisoned(t.task.inst) ); if (candidates.length === 0) return false; const thread = chooseCandidate(candidates); const inst = thread.task.inst; - inst.enterFrom(null); - // Deliberately NOT a `finally`: if the resumed thread traps, the reference - // never reaches `leave_to` either (definitions.py `Store.tick`, line 597, - // where a Trap propagates out of `thread.resume()`), so the instance stays - // locked — the Component Model's instance poisoning. See the `poison` - // helper in exec/boundary.ts for the full rationale. - // - // Capability signals are the exception, for the same reason as there: a - // `NeedsJspi`/`PendingCapability` marks an operation this runtime cannot - // perform, not a component fault. In the reference that operation blocks - // and then completes, so `leave_to` *is* reached and the instance stays - // enterable — poisoning here would turn one unsupported operation into a - // permanently dead instance. + // A trap out of the resumption poisons the instance (polyengine's named + // divergence: a per-instance corpse where wasmtime kills the whole store). + // Capability signals are the exception: a `NeedsJspi`/`PendingCapability` + // marks an operation this runtime cannot perform, not a component fault — + // in the reference that operation blocks and then completes, so poisoning + // here would turn one unsupported operation into a permanently dead + // instance. try { thread.resume(); } catch (e) { - if (e instanceof NeedsJspi || e instanceof PendingCapability) { - inst.leaveTo(null); - } else { - // The bracket stays broken (instance poisoned, comment above), so - // its live stream/future ends can never rendezvous again — retire - // them so parked host peers settle instead of hanging (#66). - // - // The synthetic root (plan v3 amendment 4) is released, though: it is - // in this entry's entering set but must not turn per-instance - // poisoning into store-wide poisoning. See - // `ComponentInstanceState.releaseSyntheticRootOnPoison`. + if (!(e instanceof NeedsJspi) && !(e instanceof PendingCapability)) { + // Poisoned: its live stream/future ends can never rendezvous again — + // retire them so parked host peers settle instead of hanging (#66). // // Routed through `notifyInstancePoisoned` (not the raw hook) so the // poison MARKER is recorded too (polyengine#145): `Thread.resumeWith`'s // quiet-retire of late settled tails and `dispatchableTail`'s // dispatch-or-defer decision (#156) both read it, and without the - // marker a settled tail of this instance would hit the backstop - // assert or defer forever. - inst.releaseSyntheticRootOnPoison?.(); + // marker a settled tail of this instance would defer forever. notifyInstancePoisoned( inst as unknown as { handles: Iterable }, e, @@ -1269,7 +1231,6 @@ export class Store { } throw e; } - inst.leaveTo(null); return true; } } diff --git a/runtime/src/task/streams.ts b/runtime/src/task/streams.ts index 146e513..e34d445 100644 --- a/runtime/src/task/streams.ts +++ b/runtime/src/task/streams.ts @@ -997,22 +997,26 @@ interface PoisonedInstanceLike { * **poisoned** guest instance is retired silently via `resetPending`. * Notifying it would queue a phantom event into the corpse's waitables, and * a later driving loop servicing it would resume machinery whose instance - * can no longer be entered (`tick` asserts enterability). Host sentinels are + * can no longer be entered (`tick` excludes poisoned instances). Host sentinels are * not instances at all, so they are always notified. * * #100: THE HEALTH TEST IS "POISONED", NOT "`mayEnter === false`". The - * original test used non-enterability as a proxy for deadness. The proxy is - * unsound in one direction, and the unsoundness stranded healthy tasks: + * original test used non-enterability as a proxy for deadness. Settled twice + * over now: CM#705 (polyengine#173) deleted `may_enter` from the reference + * outright, so the marker is not merely the better test but the only one + * left. The original argument, kept because it explains what the marker is + * for — the proxy was unsound in one direction, and the unsoundness stranded + * healthy tasks: * * * (sound half, #84 audit) a healthy guest peer always parks with * `mayEnter === true`. Every park — the callback ABI's waitable-set wait, * and equally a sync-lowered/JSPI peer blocked inside `finishCopy`'s * SITE 4 via `blockCurrentActivation` — yields the thread out of the - * scheduler's enter/leave bracket, and the bracket's `leaveTo` runs on the - * way out (task/scheduler.ts `Store.tick` :905-917, task/thread.ts - * `Thread.resumeWith` :157-179, whose resume-side - * `assert_(mayEnterFrom(null))` would fire otherwise). Blocking inside a - * wasm frame does NOT hold the enter bracket. + * scheduler's enter/leave bracket, and the bracket's `leaveTo` ran on the + * way out — `Thread.resumeWith`'s resume-side + * `assert_(mayEnterFrom(null))` would have fired otherwise. Blocking + * inside a wasm frame did NOT hold the enter bracket. (Both the bracket + * and that assert are gone as of polyengine#173.) * * (unsound converse) `mayEnter === false` does not imply "poisoned". An * instance that is merely mid-call is also non-enterable, and a CALLER * instance stays non-enterable for the whole duration of a @@ -1024,7 +1028,7 @@ interface PoisonedInstanceLike { * exact outcome #66 exists to prevent. * * So the test consults the poison marker itself. It is per-instance and - * recorded at the single seam every bracket-break site routes through + * recorded at the single seam every poisoning site routes through * (`notifyInstancePoisoned`, task/scheduler.ts: exec/boundary.ts `poison`, * `Store.tick`, `Thread.resumeWith`, the FACT cross-component catches in * intrinsics/fact_calls.ts, and cabi/handles.ts's gated destructor call), @@ -1038,7 +1042,7 @@ interface PoisonedInstanceLike { * Why this does not re-open review B2 (phantom events into a corpse): the * concern is that a DROPPED event queued onto a waitable of an instance that * can never be entered again would be serviced by a later driving loop and - * resume machinery whose `tick` asserts enterability. "Can never be entered + * resume machinery `tick` deliberately excludes. "Can never be entered * again" is precisely poisoning — a mid-call instance's `mayEnter` is * restored by its own `leaveTo` when the call returns, and its parked task * then resumes normally and consumes the event. The narrowed predicate @@ -1072,8 +1076,9 @@ export function dropSharedForTeardown( * Retire every live stream/future end in a trap-poisoned instance's handle * table (#66). * - * Rationale: after a trap breaks the enter/leave bracket, `mayEnter` stays - * false forever, so no task of this instance can ever rendezvous again. Its + * Rationale: a trapped instance is a corpse (polyengine's per-instance + * poisoning divergence), so no task of this instance can ever rendezvous + * again. Its * table's `CopyEnd`s are therefore unreachable-forever — leaving their shared * objects live strands the peers: a parked HOST operation never settles (its * promise hangs), and a LATER host operation would "succeed" against the @@ -1081,7 +1086,7 @@ export function dropSharedForTeardown( * Dropping the shared object now converts both into the spec-shaped DROPPED * outcome, and the recorded failure lets the embedder layer brand it. * - * Called from every bracket-break site — exec/boundary.ts `poison()` (the + * Called from every poisoning site — exec/boundary.ts `poison()` (the * sync-lift path), scheduler.ts `Store.tick` and thread.ts * `Thread.resumeWith` (traps during a resumed thread), and the FACT * cross-component catches (intrinsics/fact_calls.ts, callee side) — with the @@ -1159,7 +1164,7 @@ export function retireInstanceAsyncEnds( if (failed) throw first; } -// `Store.tick`'s bracket-break site reaches the walk through this seam (its +// `Store.tick`'s poisoning site reaches the walk through this seam (its // module cannot import ours — see `setOnInstancePoisoned`); the sync-lift // site (exec/boundary.ts `poison`) imports it directly. setOnInstancePoisoned(retireInstanceAsyncEnds); diff --git a/runtime/src/task/thread.ts b/runtime/src/task/thread.ts index 9ef621f..64c8af4 100644 --- a/runtime/src/task/thread.ts +++ b/runtime/src/task/thread.ts @@ -141,59 +141,37 @@ export class Thread implements SchedulableThread { this.awaiting = null; this.#store.awaiting.delete(this); this.#state = "suspended"; - // The reentrance bracket, exactly as `Store.tick` puts around `resume()`. + // Not a bracketed resumption: post-CM#705 (definitions.py @ 2f13265) + // `Store.tick` resumes a ready thread with no enter/leave bracket at all, + // and this path — the same thread body, woken by a Promise instead of a + // ready-condition — matches it. // - // Every thread resumption in the reference runs under the instance's - // entered lock, and a trap propagating out of the resumed thread skips - // `leave_to` — which is the Component Model's instance poisoning - // (definitions.py `Store.tick` line 597; see the matching comment in - // scheduler.ts). This path is a resumption too — the value arrived - // through a Promise instead of a ready-condition, but the thread body - // (and any wasm it runs) is the same — so it takes the same bracket. - // Without it, a trap delivered as an `awaitValue` rejection (how EVERY - // guest trap in a suspended activation arrives under jspi, pin (e)) - // unwound cleanly and the instance stayed enterable: the second call of - // `builtin-trap-poisons-instance.wast` then re-ran the guest and - // reported "cannot drop busy stream" where the suite demands the + // What the catch preserves is polyengine's per-instance poisoning, which + // must be MARKER-recorded here specifically: a trap delivered as an + // `awaitValue` rejection is how EVERY guest trap in a suspended + // activation arrives under jspi (pin (e)), and if it unwound silently the + // second call of `builtin-trap-poisons-instance.wast` would re-run the + // guest and report "cannot drop busy stream" where the suite demands the // poisoned-instance "cannot enter component instance". // - // Capability signals release the lock, for the same reason as in `tick`: + // Capability signals do not poison, for the same reason as in `tick`: // they mark the RUNTIME incomplete, not the component faulted. const inst = this.task.inst; // A poisoned instance's parked segments never run again: this settle // belongs to an activation that was in flight when a SIBLING activation - // trapped (the trap kept the reentrance lock — CM poisoning — and #66 - // retired the handle tables). Resuming would re-enter the corpse, and - // asserting turned one legible trap into an assert cascade (the - // wosh-M2 shape: `list too long`, then this assert as second victim). - // Retire quietly: the abandoned call's own driver reports, via its - // deadlock trap naming the export. + // trapped (#66 retired the handle tables). Resuming would re-enter the + // corpse, and asserting turned one legible trap into an assert cascade + // (the wosh-M2 shape: `list too long`, then this assert as second + // victim). Retire quietly: the abandoned call's own driver reports, via + // its deadlock trap naming the export. if (isInstancePoisoned(inst)) return; - // The enterability check below is an internal BACKSTOP, not a live gate: - // every dispatch site (`Store.serviceSettled`, `driveAsync`'s race-winner - // path) now guards enterable-or-poisoned before calling and DEFERS the - // tail otherwise (issue #156) — under the shared synthetic root, a host - // entry into any instance of the graph makes every sibling - // non-enterable, so this assert was reachable, and (mutating before - // asserting) it stranded the thread and lost the settle. It stays to - // protect the invariant for any future caller. - assert_( - inst.mayEnterFrom(null), - "resumeWith: parked thread's instance is not enterable from the host", - ); - inst.enterFrom(null); try { this.#resumeInternal(value, failure); } catch (e) { - if (e instanceof NeedsJspi || e instanceof PendingCapability) { - inst.leaveTo(null); - } else { - // The bracket stays broken (instance poisoned, comment above) — same - // as `Store.tick`: retire the poisoned table's stream/future ends so - // parked host peers settle instead of hanging (#66), and release the - // synthetic root so the poisoning stays per-instance (plan v3 - // amendment 4; `releaseSyntheticRootOnPoison`). - inst.releaseSyntheticRootOnPoison(); + if (!(e instanceof NeedsJspi) && !(e instanceof PendingCapability)) { + // Retire the poisoned table's stream/future ends so parked host peers + // settle instead of hanging (#66), and record the marker — the whole + // entry-refusal mechanism since #251's re-key. notifyInstancePoisoned( inst as unknown as { handles: Iterable }, e, @@ -201,7 +179,6 @@ export class Thread implements SchedulableThread { } throw e; } - inst.leaveTo(null); } resume(cancelled: Cancelled = CANCELLED_FALSE): void { diff --git a/runtime/tests/cancel_bracket_race_test.ts b/runtime/tests/cancel_bracket_race_test.ts index 38e8ffb..48e6a41 100644 --- a/runtime/tests/cancel_bracket_race_test.ts +++ b/runtime/tests/cancel_bracket_race_test.ts @@ -1,30 +1,26 @@ -// #92 (test half): a seeded-shuffle regression pinning the "cancel-bracket +// #92 (test half): a seeded-shuffle regression pinning the "cancel-delivery // timing" window named in issue #92. // -// `Task.requestCancellation` (task/mod.ts:369-428) brackets its delivery with -// `inst.enterFrom(caller)` / `inst.leaveTo(caller)` around -// `SuspensionPoint.resume()` — but under jspi, `resume()` only *settles* the -// suspended activation's Promise; the resumed wasm frame's own continuation -// (its remaining built-ins, `exit-sync-call`, etc.) runs on a LATER -// microtask, after `leaveTo` has already released the bracket. A concurrent -// host EXPORT call that enters the same instance in between goes through -// `exec/boundary.ts`'s `createLiftedFunction` (:1002-1011 in the review's -// line numbers), which gates only on `inst.mayEnterFrom(null)` — it does not -// consult the scheduler's `resumingThread` slot the way `Store.tick` -// (scheduler.ts:876) does. +// The window: under jspi, `Task.requestCancellation`'s +// `SuspensionPoint.resume()` only *settles* the suspended activation's +// Promise; the resumed wasm frame's own continuation (its remaining +// built-ins, `exit-sync-call`, etc.) runs on a LATER microtask. A concurrent +// host EXPORT call can enter the same instance in between. // -// This test does not *construct* a divergence (issue #92 says none was -// found): it pins that (a) the window is real — `mayEnterFrom` reports the -// instance free immediately after `requestCancellation` returns, even though -// the cancelled activation's continuation has not run yet — and (b) driving -// a second export call through that window today does not double-resume -// anything or leave inconsistent final state, i.e. the corpus-soundness -// claim in the issue. It is included in `just sched-seeds` so any future -// schedule-order dependence here is caught. +// Originally the delivery was wrapped in an `enterFrom`/`leaveTo` bracket and +// the question was whether that bracket closed too early. CM#705 +// (polyengine#173) removed the bracket — and the whole reentrance gate — +// from the reference and from this runtime, so the concurrent entry is now +// unconditionally ADMITTED by design rather than by an accident of bracket +// timing. The behavioral pin is unchanged and still worth keeping: driving a +// second export call through that window does not double-resume anything or +// leave inconsistent final state. It is included in `just sched-seeds` so any +// future schedule-order dependence here is caught. import { assertEq } from "./support/asserts.ts"; import { createWaitableSetWait } from "../src/intrinsics/async_builtins.ts"; import { createLiftedFunction, newStats, type ResolvedOptions } from "../src/exec/boundary.ts"; +import { entryRefusal } from "../src/task/scheduler.ts"; import { ComponentInstanceState, popCurrentThread, @@ -46,9 +42,9 @@ const CALLBACK_OPTS: TaskOptions = { }; Deno.test( - "#92: requestCancellation's enter/leave bracket releases before the " + - "resumed jspi activation's continuation runs — a concurrent export " + - "call can enter in between without an observed double-resume", + "#92: a concurrent export call entering between a cancellation delivery " + + "and the resumed jspi activation's continuation is admitted, with no " + + "double-resume and consistent final state", async () => { const store = new Store(); const inst = new ComponentInstanceState(0, store); @@ -103,27 +99,24 @@ Deno.test( assertEq(typeof parked, "object"); // a Promise, per blockCurrentActivation. assertEq(store.waiting.length, 1); - // Sanity: the instance is correctly gated WHILE parked. - assertEq(inst.mayEnterFrom(null), true); // parked tasks release the gate (#43). + // Sanity: nothing refuses entry while the activation is parked. + assertEq(entryRefusal(inst, null, "base"), null); // Deliver the cancellation exactly as `Task.requestCancellation` does: - // finds the parked SuspensionPoint as a candidate (it is registered with - // `task === task` and `cancellable === true`), brackets `enter/leave` - // around its (synchronous) `resume`. + // it finds the parked SuspensionPoint as a candidate (registered with + // `task === task` and `cancellable === true`) and resumes it. task.requestCancellation(null); - // THE WINDOW: `requestCancellation` has already called `leaveTo`, so the - // instance looks fully free — even though the resumed activation's own - // continuation (the `.then()` the engine attached to the settled - // Promise) has not run yet. This is exactly the gap #92 names. - assertEq(inst.mayEnterFrom(null), true); + // THE WINDOW: the resumed activation's own continuation (the `.then()` + // the engine attached to the settled Promise) has not run yet, and the + // instance admits entry. This is exactly the gap #92 names. + assertEq(entryRefusal(inst, null, "base"), null); assertEq(task.state, "cancel-delivered"); - // Drive a concurrent EXPORT call into the SAME instance through the - // real host-entry path (`createLiftedFunction`), which gates only on - // `mayEnterFrom` — not on the scheduler's `resumingThread` claim that - // `Store.tick` respects (scheduler.ts:876). If this traps or corrupts - // state, the divergence is no longer merely theoretical. + // Drive a concurrent EXPORT call into the SAME instance through the real + // host-entry path (`createLiftedFunction`), which post-CM#705 refuses + // only a poisoned instance. If this traps or corrupts state, the + // divergence is no longer merely theoretical. const syncFt: FuncType = { params: [], results: [], async: false }; const exportOpts: ResolvedOptions = { stringEncoding: "utf8", @@ -149,9 +142,8 @@ Deno.test( } catch (e) { raised = e; } - // Pin the CURRENT observed behaviour: the concurrent entry is admitted - // (no "reentrance forbidden" trap), matching the issue's finding that - // `mayEnterFrom` does not consult `resumingThread`. + // The concurrent entry is admitted — now by the merged reference's own + // rule, not merely as an observed quirk. assertEq(raised, undefined); // Let the cancelled activation's own continuation actually run (the diff --git a/runtime/tests/dtor_normalization_test.ts b/runtime/tests/dtor_normalization_test.ts index 486b0f3..75c058c 100644 --- a/runtime/tests/dtor_normalization_test.ts +++ b/runtime/tests/dtor_normalization_test.ts @@ -18,7 +18,11 @@ // #156 class. // // Both are structural consequences of the missing Task/Thread, and both are -// gone now that the dtor runs through `createLiftedFunction`. +// gone now that the dtor runs through `createLiftedFunction`. CM#705 +// (polyengine#173) has since removed the gate itself, so neither shape is +// even expressible any more; the pins below are restated in terms of what is +// still observable — the dtor's own task, scheduler resumability, and the +// fact that a dtor may re-enter a LIVE instance at all. import { ResourceTypeInfo } from "../src/cabi/mod.ts"; import { @@ -26,7 +30,7 @@ import { Store, storeQuiescent, } from "../src/task/mod.ts"; -import { currentTask } from "../src/task/scheduler.ts"; +import { currentTask, entryRefusal } from "../src/task/scheduler.ts"; import { blockCurrentActivation } from "../src/jspi/mod.ts"; import { driveStoreAsync, hostDtorCall } from "../src/exec/boundary.ts"; import { assertEq } from "./support/asserts.ts"; @@ -63,11 +67,11 @@ Deno.test("#160: a dtor parked on a scheduler-resumable suspension point complet hostDtorCall(rt, 77); - // The park happened, and the entry bracket was RELEASED at it: the impl is - // host-enterable, which is precisely what lets `tick` resume the point - // below. Pre-#160 this was `false` and the store wedged here forever. + // The park happened, and `tick` can resume the point below. Pre-#160 the + // held bracket made the impl non-enterable and the store wedged forever; + // post-#705 nothing can make it non-enterable except poisoning. assertEq(finished, false); - assertEq(impl.mayEnterFrom(null), true); + assertEq(entryRefusal(impl, null, "base"), null); assertEq(store.waiting.length >= 1, true); // NOT advertised as external work: the settlement needs this scheduler. assertEq(store.pendingHostCalls.size, 0); @@ -78,33 +82,41 @@ Deno.test("#160: a dtor parked on a scheduler-resumable suspension point complet assertEq(finished, true); assertEq(store.waiting.length, 0); assertEq(storeQuiescent(store), true); - assertEq(impl.mayEnterFrom(null), true); + assertEq(entryRefusal(impl, null, "base"), null); assertEq(store.hostFailure, undefined); }); -Deno.test("#160/#156: a sibling instance stays enterable while a dtor is in flight", async () => { +Deno.test("#160/#173: a dtor may run while its own instance is LIVE", async () => { + // REPLACES the "#160/#156: a sibling instance stays enterable" pin, which + // is trivial now (nothing can be non-enterable). The stronger merged + // property: `canon_resource_drop` lifts the dtor with no gate at all + // (definitions.py @ 2f13265), so a dtor whose implementing instance is in + // the middle of a host-initiated activation is valid and both complete. const store = new Store(); const impl = new ComponentInstanceState(1, store); - const sibling = new ComponentInstanceState(2, store); - let resolveDtor: () => void = () => {}; - const rt = new ResourceTypeInfo( + // A first dtor activation of `impl`, parked mid-flight. + let resolveFirst: () => void = () => {}; + const slow = new ResourceTypeInfo( impl, - (() => new Promise((r) => (resolveDtor = r))) as unknown as ( + (() => new Promise((r) => (resolveFirst = r))) as unknown as ( rep: number, ) => void, ); - hostDtorCall(rt, 5); + hostDtorCall(slow, 5); - // Pre-#160 the held `enterFrom(null)` locked the synthetic root shared by - // the component's instances, so this was `false` for as long as the dtor - // ran — an unrelated export call on `sibling` would have trapped with - // "cannot enter component instance". - assertEq(sibling.mayEnterFrom(null), true); - assertEq(impl.mayEnterFrom(null), true); + // A SECOND, synchronous dtor of the same instance, entered while the first + // is still in flight. Pre-#705 this was refused ("cannot enter component + // instance"); now it simply runs. + let ranNested = 0; + const quick = new ResourceTypeInfo(impl, (() => { + ranNested += 1; + }) as unknown as (rep: number) => void); + hostDtorCall(quick, 6); + assertEq(ranNested, 1, "the nested dtor ran; nothing was refused"); - resolveDtor(); - await driveStoreAsync(store, () => storeQuiescent(store), "sibling drain"); - assertEq(sibling.mayEnterFrom(null), true); - assertEq(impl.mayEnterFrom(null), true); + resolveFirst(); + await driveStoreAsync(store, () => storeQuiescent(store), "dtor drain"); + assertEq(entryRefusal(impl, null, "base"), null); + assertEq(store.hostFailure, undefined); }); diff --git a/runtime/tests/enter_sync_call_reentrance_test.ts b/runtime/tests/enter_sync_call_reentrance_test.ts index 9ae0c2f..a77c931 100644 --- a/runtime/tests/enter_sync_call_reentrance_test.ts +++ b/runtime/tests/enter_sync_call_reentrance_test.ts @@ -1,24 +1,26 @@ -// The reentrance gate on the sync fused-adapter bracket (issue #99). +// Entry refusal on the sync fused-adapter bracket (issues #99, #173). // -// Reference chain, component-model @ 73b7ad5 -// `design/mvp/canonical-abi/definitions.py`: -// * `canon_lower` line 2312 invokes the callee `FuncInst` with -// `caller = thread.task.inst`; -// * that `FuncInst` is `Store.lift`'s `func_inst`, lines 578-585, whose -// first statement is `trap_if(not inst.may_enter_from(caller))` (581); -// * `may_enter_from` (214) tests every instance in -// `entering_set(caller) = callee.self_and_ancestors() -// - caller.self_and_ancestors()` (230-234). +// INVERTED by polyengine#173. The reference chain that used to gate here — +// `canon_lower` invoking the callee `FuncInst` with `caller = +// thread.task.inst`, and `Store.lift`'s `trap_if(not +// inst.may_enter_from(caller))` over `entering_set(caller)` — is GONE: +// CM#705 (definitions.py @ 2f13265) removed `may_enter`, `entering_set`, +// `enter_from`, `leave_to` and `ComponentInstance.parent` outright, and +// `Store.lift` now runs `canon_lift` unconditionally. A sibling cycle +// A -> C -> A through the trampoline therefore does NOT trap. // -// So a *sibling* callee that is currently entered traps, an idle sibling does -// not, and same-instance / ancestor pairs have an empty entering set and never -// trap here (FACT traps those statically instead -- -// wasmtime-environ 47.0.3 `fact/trampoline.rs:120-127`). +// wasmtime agreed all along: `enter_guest_sync_call` +// (47.0.3 `runtime/component/concurrent.rs:1723`) performs no reentrance +// check, and same-instance / ancestor pairs are trapped statically by FACT +// (`fact/trampoline.rs:120-127`), not here. +// +// What DOES still refuse at this site is polyengine's named divergence: a +// POISONED callee is a corpse, and the refusal names the original trap +// (polyengine#145). That is the surviving pin below. // // These tests drive the `enter-sync-call` trampoline directly, because the -// trapping shape is not constructible as a component: mutual sibling imports -// are rejected by validation (instance imports form a DAG). See the -// adjudication note on the trampoline itself. +// shapes involved are not constructible as components: mutual sibling +// imports are rejected by validation (instance imports form a DAG). import { assertEq } from "./support/asserts.ts"; import { @@ -28,6 +30,7 @@ import { } from "../src/intrinsics/mod.ts"; import { newStats } from "../src/exec/boundary.ts"; import { ComponentInstanceState, Store } from "../src/task/mod.ts"; +import { notifyInstancePoisoned } from "../src/task/scheduler.ts"; function fixture() { const store = new Store(); @@ -57,21 +60,29 @@ function fixture() { const A = 0; const C = 1; -Deno.test("enter-sync-call: idle sibling callee is enterable", () => { - const { enter, exit, inst, syncCallStack } = fixture(); - inst(A).mayEnter = false; // the host entered A (boundary `enterFrom(null)`) +Deno.test("enter-sync-call: an idle sibling callee is enterable", () => { + const { enter, exit, syncCallStack } = fixture(); enter(A, 0, C); assertEq(syncCallStack.length, 1, "bracket opened"); exit(); assertEq(syncCallStack.length, 0, "bracket closed"); }); -Deno.test("enter-sync-call: sibling cycle A -> C -> A traps", () => { +Deno.test("enter-sync-call: a sibling cycle A -> C -> A no longer traps (CM#705)", () => { + // Was: "sibling cycle A -> C -> A traps". Host entered A; A is mid-call + // into C; C calls back into A. Post-CM#705 that is simply a valid call. + const { enter, exit, syncCallStack } = fixture(); + enter(A, 0, C); + enter(C, 0, A); + assertEq(syncCallStack.length, 2, "both brackets opened, nothing refused"); + exit(); + exit(); + assertEq(syncCallStack.length, 0); +}); + +Deno.test("enter-sync-call: a POISONED callee is refused, naming the trap", () => { const { enter, inst } = fixture(); - // Host entered A; A is mid-call into C, so C is entered too. The cycle is - // C calling back into A. - inst(A).mayEnter = false; - inst(C).mayEnter = false; + notifyInstancePoisoned(inst(A), new Error("earlier boom")); let msg = ""; try { enter(C, 0, A); @@ -81,37 +92,28 @@ Deno.test("enter-sync-call: sibling cycle A -> C -> A traps", () => { assertEq( msg.includes("cannot enter component instance"), true, - `expected the reentrance trap, got: ${msg || ""}`, - ); - // polyengine#145: a TRANSIENT reentrance refusal (live-call overlap, nothing - // poisoned) must stay byte-identical — the poison-cause suffix is what - // distinguishes the corpse from the crowd. - assertEq( - msg.includes("instance poisoned by"), - false, - `transient refusal must not claim poisoning: ${msg}`, + `expected the poisoned-corpse refusal, got: ${msg || ""}`, ); + // polyengine#145 ask 1: the refusal names the original trap. + assertEq(msg.includes("instance poisoned by"), true, msg); + assertEq(msg.includes("earlier boom"), true, msg); +}); + +Deno.test("enter-sync-call: a poisoned instance calling ITSELF passes vacuously", () => { + // `entryRefusal`'s `caller !== callee` guard: the pre-#705 entering set + // `{A} - {A}` was empty, and the vacuous pass is preserved. + const { enter, inst } = fixture(); + notifyInstancePoisoned(inst(A), new Error("earlier boom")); + enter(A, 0, A); }); Deno.test("enter-sync-call: an acyclic sibling chain A -> B -> C never traps", () => { - const { enter, exit, inst } = fixture(); + const { enter, exit, syncCallStack } = fixture(); const B = 2; - inst(A).mayEnter = false; enter(A, 0, B); enter(B, 0, C); + assertEq(syncCallStack.length, 2); exit(); exit(); - // Nothing above mutates `mayEnter`; the point is that the gate stays quiet - // for the shape `test/linking/unit.wast` (the sibling relift chain) uses. - assertEq(inst(B).mayEnter, true); - assertEq(inst(C).mayEnter, true); -}); - -Deno.test("enter-sync-call: same-instance pair has an empty entering set", () => { - const { enter, inst } = fixture(); - // definitions.py `entering_set`: `{A} - {A}` is empty, so `may_enter_from` - // is vacuously true even with `may_enter == False`. FACT never emits this - // pair (trampoline.rs:120-127) but the gate must agree with the reference. - inst(A).mayEnter = false; - enter(A, 0, A); + assertEq(syncCallStack.length, 0); }); diff --git a/runtime/tests/integration/e2e_hello_test.ts b/runtime/tests/integration/e2e_hello_test.ts index 105795f..128dac9 100644 --- a/runtime/tests/integration/e2e_hello_test.ts +++ b/runtime/tests/integration/e2e_hello_test.ts @@ -13,6 +13,7 @@ import { assertEq, assertTrap } from "../support/asserts.ts"; import { Translator } from "../../src/shim/mod.ts"; import { instantiateComponent } from "../../src/exec/mod.ts"; import { SUPPORTED_FORMAT_VERSION } from "../../src/plan/mod.ts"; +import { isInstancePoisoned } from "../../src/task/scheduler.ts"; function assert(cond: boolean, msg: string): asserts cond { if (!cond) throw new Error(`assertion failed: ${msg}`); @@ -123,7 +124,7 @@ Deno.test("hello: executor validates formatVersion and hash", async () => { assert(failed.includes("sha256"), `got: ${failed}`); }); -Deno.test("task model: reentrance gate blocks concurrent entry", async () => { +Deno.test("task model: reentrance is permitted; a failed call poisons", async () => { const translator = await Translator.create(shimWasm); const { plan, adapters } = translator.translate(helloWasm); const component = await instantiateComponent({ @@ -134,17 +135,15 @@ Deno.test("task model: reentrance gate blocks concurrent entry", async () => { const greet = component.exports.greet as (name: string) => string; greet("warm-up"); - // Simulate an in-progress activation of the same instance, as a - // transitive call back into it would observe (docs/architecture.md §4.3 item 4: the - // gates are ours to enforce; the engine permits reentry the CM forbids). + // INVERTED by polyengine#173 (CM#705). This used to simulate an + // in-progress activation of the same instance and require the next host + // entry to trap. The merged reference (definitions.py @ 2f13265) has no + // `may_enter`, no `entering_set` and no bracket in `Store.lift`, so entry + // into a live instance is valid. The live host-mediated shape is pinned + // end-to-end in e2e_imports_test.ts ("a host import may synchronously + // re-enter its own instance"); here we only pin that nothing refuses. const inst = component.componentInstances[0]; - inst.enter(); - try { - assertTrap(() => greet("reentrant"), "reentrant call"); - } finally { - inst.leave(); - } - // Gate released: calls work again. + assertEq(greet("reentrant"), "Hello, reentrant!"); assertEq(greet("after"), "Hello, after!"); // Host input of the wrong JS type fails as a host-side error, not a CM trap @@ -152,9 +151,9 @@ Deno.test("task model: reentrance gate blocks concurrent entry", async () => { // runtime/README.md). It still poisons: the failure happens *inside* the // task, after `task.start()`, so the guest may already have run realloc and // half-written its argument buffer — the instance is in exactly the - // indeterminate state poisoning exists for. definitions.py `Store.lift` - // (line 578) makes no exception either: anything propagating out of - // `canon_lift` skips `leave_to`. + // indeterminate state poisoning exists for. (Poisoning is polyengine's + // named divergence — a per-instance corpse where wasmtime kills the whole + // store — and since CM#705 it is the ONLY reason an entry is refused.) let threw = false; try { greet(123 as unknown as string); @@ -162,7 +161,7 @@ Deno.test("task model: reentrance gate blocks concurrent entry", async () => { threw = true; } assert(threw, "number lowered as string must fail"); - assert(!inst.mayEnter, "a failed call must poison the instance"); + assert(isInstancePoisoned(inst), "a failed call must poison the instance"); assertTrap(() => greet("after-poison"), "cannot enter component instance"); // polyengine#145 ask 1: the poisoned refusal names the original cause, so the // embedder is not sent chasing transient caller-side call overlap. The @@ -175,7 +174,7 @@ Deno.test("task model: reentrance gate blocks concurrent entry", async () => { refusal = String((e as Error).message ?? e); } assert( - refusal.includes("(reentrance forbidden)"), + refusal.includes("cannot enter component instance"), `refusal must keep the base wording, got: ${refusal}`, ); assert( diff --git a/runtime/tests/integration/e2e_imports_test.ts b/runtime/tests/integration/e2e_imports_test.ts index b2f3f76..8d9ae97 100644 --- a/runtime/tests/integration/e2e_imports_test.ts +++ b/runtime/tests/integration/e2e_imports_test.ts @@ -18,6 +18,7 @@ import { import { PlanError } from "../../src/plan/mod.ts"; import { ResourceHandle } from "../../src/cabi/mod.ts"; import { SyncCallScope } from "../../src/intrinsics/mod.ts"; +import { isInstancePoisoned } from "../../src/task/scheduler.ts"; const root = new URL("../../../", import.meta.url); @@ -259,10 +260,9 @@ Deno.test({ `expected a lend trap, got: ${error}`, ); // The trap escaped a FACT sync-call bracket mid-argument-translation, so - // the entered instance is now poisoned: definitions.py `Store.lift` - // (line 578) never reaches `leave_to` when a Trap propagates out of - // `canon_lift`, and `test/async/builtin-trap-poisons-instance.wast` - // asserts that the next call reports the reentrance error. + // the entered instance is now poisoned — polyengine's per-instance corpse + // divergence, which `test/async/builtin-trap-poisons-instance.wast` + // pins by requiring the next call to be refused. assertEq( String(catchOf(() => fn(c, "run")())).includes( "cannot enter component instance", @@ -276,10 +276,12 @@ Deno.test({ // restored and the shared sync-call scope stack emptied (see `unwind` in // exec/boundary.ts — that hygiene is still load-bearing for *siblings* // even though the entered instance is now poisoned). - const poisoned = c.componentInstances.filter((i) => i && !i.mayEnter); + const poisoned = c.componentInstances.filter((i) => + i && isInstancePoisoned(i) + ); assertEq(poisoned.length, 1); for (const i of c.componentInstances) { - if (i && i.mayEnter) assertEq(i.mayLeave, true); + if (i && !isInstancePoisoned(i)) assertEq(i.mayLeave, true); } // A fresh instantiation is unaffected and runs the success path. const c2 = await instantiate("relend-borrow", {}); @@ -362,3 +364,46 @@ Deno.test({ assertEq(fn(c, "run")(), 3); }, }); + +// --------------------------------------------------------------------------- +// polyengine#173 (CM#705): HOST-MEDIATED REENTRANCE IS VALID +// --------------------------------------------------------------------------- + +Deno.test({ + name: "reentrance: a host import may synchronously re-enter its own instance", + ignore: shimWasm === null, + fn: async () => { + // The headline of the CM#705 adoption. Host calls `run`; `run` calls the + // host import `log`; the host handler synchronously calls `run` on the + // SAME instance again. This used to trap at the host-entry gate + // ("cannot enter component instance"): the reference's `Store.lift` + // refused a second entry while the first was live. + // + // Merged reference (definitions.py @ 2f13265): `Store.lift` runs + // `canon_lift` with no gate, `may_enter`/`entering_set`/`enter_from`/ + // `leave_to` do not exist, and nesting host entries is simply legal. + // Both calls complete. + const logged: number[] = []; + let depth = 0; + let inner: number | undefined; + const c: { exports: Record } = await instantiate("imports", { + "log": (x: unknown) => { + logged.push(x as number); + if (depth === 0) { + depth = 1; + // Re-entry into the live instance, host-mediated. + inner = fn(c, "run")(10, 20) as number; + } + }, + "host:api/math": { + add: (a: unknown, b: unknown) => (a as number) + (b as number), + greet: (who: unknown) => `Hello, ${who as string}!`, + }, + }); + + const outer = fn(c, "run")(1, 2) as number; + assertEq(outer, 3, "the outer call completed"); + assertEq(inner, 30, "and so did the re-entrant inner call"); + assertEq(logged.join(","), "3,30", "both activations reached the import"); + }, +}); diff --git a/runtime/tests/integration/e2e_suite_test.ts b/runtime/tests/integration/e2e_suite_test.ts index 315b50f..b5b6885 100644 --- a/runtime/tests/integration/e2e_suite_test.ts +++ b/runtime/tests/integration/e2e_suite_test.ts @@ -14,6 +14,7 @@ // cargo run -p testgen import { assertEq } from "../support/asserts.ts"; +import { isInstancePoisoned } from "../../src/task/scheduler.ts"; import { Translator } from "../../src/shim/mod.ts"; import { instantiateComponent } from "../../src/exec/mod.ts"; import { TranslateError } from "../../src/plan/mod.ts"; @@ -182,12 +183,15 @@ Deno.test({ // traps build a fresh component instance for each one. const c = await instantiate("resources", "borrows.0.wasm"); assertTraps(() => fn(c, "lend-trap")(), "while borrowed"); - // Same instance, second attempt: the reentrance gate, not the lend check. + // Same instance, second attempt: the poisoned-corpse refusal, not the + // lend check. assertTraps(() => fn(c, "lend-trap")(), "cannot enter component instance"); // Exactly one instance is poisoned — the one this call entered. Siblings - // stay usable, which is why the lock is per-instance rather than a + // stay usable, which is why poisoning is per-instance rather than a // whole-store poison the way wasmtime does it. - const poisoned = c.componentInstances.filter((i) => i && !i.mayEnter); + const poisoned = c.componentInstances.filter((i) => + i && isInstancePoisoned(i) + ); assertEq(poisoned.length, 1); // A *fresh* instance reproduces the original, specific trap — the trap @@ -636,6 +640,9 @@ Deno.test({ // Every FACT sync-call bracket balanced, and nothing is poisoned — i.e. // no call left an instance entered. assertEq(c.stats.enterSyncCalls, c.stats.exitSyncCalls); - assertEq(c.componentInstances.some((i) => i && !i.mayEnter), false); + assertEq( + c.componentInstances.some((i) => i && isInstancePoisoned(i)), + false, + ); }, }); diff --git a/runtime/tests/jspi/bridge_test.ts b/runtime/tests/jspi/bridge_test.ts index b18e580..6fb5fbe 100644 --- a/runtime/tests/jspi/bridge_test.ts +++ b/runtime/tests/jspi/bridge_test.ts @@ -17,6 +17,7 @@ import { suspendingImport, } from "../../src/jspi/mod.ts"; import { isSupported } from "../../src/jspi/mechanics.ts"; +import { entryRefusal } from "../../src/task/scheduler.ts"; import { ComponentInstanceState, maybeCurrentThread, @@ -103,14 +104,17 @@ Deno.test("bridge: a trap computed at resume time becomes a rejection", async () assertEq(store.waiting.length, 0); }); -Deno.test("bridge: reentrance gates are ours and hold across a suspension", async () => { - // Empirical fact (d): the engine freely permits reentering an instance while - // one of its activations is suspended. The Component Model forbids it, so - // the gate must be enforced by us and must survive the suspension — the - // instance stays entered for as long as the activation is parked. +Deno.test("bridge: a suspension does not make the instance unusable", async () => { + // Was: "reentrance gates are ours and hold across a suspension". Empirical + // fact (d) is unchanged — the ENGINE freely permits reentering an instance + // while one of its activations is suspended — but the Component Model no + // longer forbids it either: CM#705 (definitions.py @ 2f13265) deleted + // `may_enter`/`enter_from`/`leave_to`, so there is no gate to hold + // (polyengine#173). What this pins now is that a suspended-and-resumed + // activation leaves the instance entirely usable: nothing is refused before, + // during or after. const store = new Store(); const inst = new ComponentInstanceState(0, store); - inst.enterFrom(null); // an activation is in flight let flag = false; const point = new SuspensionPoint( store, @@ -119,17 +123,11 @@ Deno.test("bridge: reentrance gates are ours and hold across a suspension", asyn false, () => 1, ); - // While suspended the instance is still un-enterable: a second host call - // would trap, which is exactly what `createLiftedFunction` checks. - assertEq(inst.mayEnterFrom(null), false); + assertEq(entryRefusal(inst, null, "base"), null, "entry is admitted"); flag = true; - store.tick; point.resume(); await point.promise; - // Still entered — resuming does not release the gate; leaving does. - assertEq(inst.mayEnterFrom(null), false); - inst.leaveTo(null); - assertEq(inst.mayEnterFrom(null), true); + assertEq(entryRefusal(inst, null, "base"), null, "still admitted"); }); Deno.test("bridge: abandon rejects without resuming the guest", async () => { diff --git a/runtime/tests/jspi/hop_atomicity_test.ts b/runtime/tests/jspi/hop_atomicity_test.ts index 52c57c5..c5d054e 100644 --- a/runtime/tests/jspi/hop_atomicity_test.ts +++ b/runtime/tests/jspi/hop_atomicity_test.ts @@ -14,12 +14,13 @@ // 2. the host lifts the result through that pointer — outer (ptr,len), // each inner (ptr,len), then the bytes. // -// `exec/boundary.ts` releases the reentrance bracket at the FIRST park -// (`leave()` runs before `drive`, and the lift happens later still, in -// `finishHostEntry`). A hop-park is a park, so pre-fix a SECOND host call -// could enter and run a full guest turn in that window — and if that turn -// mutates the memory the pending lift is about to read, the lift reads -// whatever the intruder left. +// Nothing holds the instance across that window: the lift happens later +// still, in `finishHostEntry`, and post-CM#705 (polyengine#173) there is no +// reentrance gate anywhere to hold in the first place. A hop-park is a park, +// so pre-fix a SECOND host call could enter and run a full guest turn in that +// window — and if that turn mutates the memory the pending lift is about to +// read, the lift reads whatever the intruder left. The hop-quiescence gate in +// `exec/boundary.ts` is the only thing standing between the two. // // In the wild: the wosh mosh engine's `tick`, returning `list>`, // died under real traffic with `Trap: list too long` — a clobbered outer diff --git a/runtime/tests/poison_cause_test.ts b/runtime/tests/poison_cause_test.ts index af97dde..72380af 100644 --- a/runtime/tests/poison_cause_test.ts +++ b/runtime/tests/poison_cause_test.ts @@ -1,9 +1,11 @@ -// polyengine#145 ask 1: entry refusals on a poisoned instance name the original -// trap. The scheduler records the FIRST poisoning cause per instance -// (follow-on failures against a corpse are noise); `withPoisonCause` appends -// it to entry-refusal messages, and a non-poisoned (transient-reentrance) -// refusal stays byte-identical to the reference wording. The e2e face of -// this is asserted in integration/e2e_hello_test.ts; the transient face in +// polyengine#145 ask 1: entry refusals on a poisoned instance name the +// original trap. The scheduler records the FIRST poisoning cause per instance +// (follow-on failures against a corpse are noise) and `withPoisonCause` +// appends it to the refusal message. Since CM#705 (polyengine#173) poisoning +// is the ONLY reason an entry is refused, so the helper's pass-through +// behavior on an unmarked instance is just the "nothing to say" case rather +// than a second refusal class. The e2e face is asserted in +// integration/e2e_hello_test.ts, the trampoline face in // enter_sync_call_reentrance_test.ts. // // No hook manipulation here: if task/streams.ts's retirement walk happens to @@ -36,7 +38,7 @@ Deno.test("poison cause: recorded, queryable, first cause wins", () => { assertEq(isInstancePoisoned(inst), true, "poisoned after notify"); assertEq(instancePoisonCause(inst) === original, true, "cause identity"); - // A later bracket break against the same corpse must not displace the + // A later trap against the same corpse must not displace the // original cause — it is the one worth reporting. notifyInstancePoisoned(inst, new Trap("second victim")); assertEq( @@ -50,17 +52,14 @@ Deno.test("poison cause: refusal message carries the original trap", () => { const inst = fakeInst(); notifyInstancePoisoned(inst, new Trap("boom in cabi_realloc")); assertEq( - withPoisonCause( - inst, - "cannot enter component instance 8 (reentrance forbidden)", - ), - "cannot enter component instance 8 (reentrance forbidden)" + + withPoisonCause(inst, "cannot enter component instance 8"), + "cannot enter component instance 8" + " — instance poisoned by: Trap: boom in cabi_realloc", ); }); -Deno.test("poison cause: transient refusal stays byte-identical", () => { - const inst = fakeInst(); // never poisoned: a live-call overlap, not a corpse +Deno.test("poison cause: an unmarked instance gets the base unchanged", () => { + const inst = fakeInst(); // never poisoned, so there is no cause to append assertEq( withPoisonCause(inst, "cannot enter component instance"), "cannot enter component instance", diff --git a/runtime/tests/realloc_may_leave_test.ts b/runtime/tests/realloc_may_leave_test.ts index f17d5e2..71715c8 100644 --- a/runtime/tests/realloc_may_leave_test.ts +++ b/runtime/tests/realloc_may_leave_test.ts @@ -8,6 +8,7 @@ // core wasm exports (the established pattern in these unit tests). import { assertEq, assertTrap } from "./support/asserts.ts"; +import { isInstancePoisoned } from "../src/task/scheduler.ts"; import { createLiftedFunction, createLoweredImport, @@ -176,10 +177,10 @@ Deno.test("#147: a host-entry realloc that lowers an import traps", () => { message.includes("may_leave violation"), `expected a may_leave violation, got: ${message}`, ); - // #91 precedent: the bracket is bare, so the trap left `may_leave` false - // and nothing tidied it up — the instance is poisoned instead. + // #91 precedent: the trap left `may_leave` false and nothing tidied it up + // — the instance is poisoned instead. assertEq(h.inst.mayLeave, false); - assertEq(h.inst.mayEnter, false); + assertEq(isInstancePoisoned(h.inst), true); }); Deno.test("#147: import-result lowering runs realloc inside the may_leave window", () => { diff --git a/runtime/tests/resource_lender_unwind_test.ts b/runtime/tests/resource_lender_unwind_test.ts index c1134f2..87ca135 100644 --- a/runtime/tests/resource_lender_unwind_test.ts +++ b/runtime/tests/resource_lender_unwind_test.ts @@ -17,6 +17,7 @@ import { type PreparedCall, } from "../src/intrinsics/fact_calls.ts"; import type { FactStartScope } from "../src/intrinsics/mod.ts"; +import { isInstancePoisoned } from "../src/task/scheduler.ts"; import { newStats } from "../src/exec/boundary.ts"; import { ComponentInstanceState, Store } from "../src/task/mod.ts"; import { NeedsJspi } from "../src/task/scheduler.ts"; @@ -127,7 +128,7 @@ Deno.test("#91: sync-start-call releases the caller's lenders when the callee tr }); assertEq(escaped === boom, true); // The callee is poisoned; the caller is not, and its handle is usable again. - assertEq(h.callee.mayEnter, false); + assertEq(isInstancePoisoned(h.callee), true); assertEq(h.handle.numLends, 0); canonResourceDrop(h.caller, h.rt, h.handleIndex); // no "still lent out" trap }); @@ -146,7 +147,7 @@ Deno.test("#91: sync-start-call releases lenders on a capability bail", () => { throw boom; }); assert(escaped instanceof NeedsJspi, `expected NeedsJspi, got ${escaped}`); - assertEq(h.callee.mayEnter, true); // not poisoned, as the class demands + assertEq(isInstancePoisoned(h.callee), false); // not poisoned, as the class demands assertEq(h.handle.numLends, 0); canonResourceDrop(h.caller, h.rt, h.handleIndex); }); @@ -158,7 +159,7 @@ Deno.test("#91: async-start-call releases the subtask's lenders when the callee throw boom; }); assertEq(escaped === boom, true); - assertEq(h.callee.mayEnter, false); + assertEq(isInstancePoisoned(h.callee), true); // The subtask never reached `report()`, so nothing else would ever deliver // its resolution and release these. assertEq(h.handle.numLends, 0); @@ -166,15 +167,14 @@ Deno.test("#91: async-start-call releases the subtask's lenders when the callee }); Deno.test("#91: a trapping post-return leaves may_leave as the reference does", () => { - // definitions.py `canon_lift` (lines 2170-2174): `may_leave = False`, the - // post-return call, `may_leave = True`. A trap in between skips the - // restore, and `Store.lift`'s `leave_to` too — the instance is poisoned and - // its flags are left exactly as the trap left them. Verified rather than - // "fixed": restoring `may_leave` here locally would contradict both. The - // obligation this runtime adds — that no *live* instance is stranded with - // `may_leave === false` — is discharged at the host boundary by - // exec/boundary.ts `unwind`, which asserts the resting state for every - // instance outside the poisoned entered set. + // definitions.py `canon_lift`: `may_leave = False`, the post-return call, + // `may_leave = True`. A trap in between skips the restore, and the instance + // is poisoned, so its flags are left exactly as the trap left them. + // Verified rather than "fixed": restoring `may_leave` here locally would + // contradict the reference. The obligation this runtime adds — that no + // *live* instance is stranded with `may_leave === false` — is discharged at + // the host boundary by exec/boundary.ts `unwind`, which asserts the resting + // state for every instance except the poisoned one. const boom = new Error("post-return trap"); const h = mkHarness(() => { throw boom; @@ -182,10 +182,10 @@ Deno.test("#91: a trapping post-return leaves may_leave as the reference does", // flags = 0 selects the sync-ABI callee, whose lift runs the post-return. const escaped = h.run("async", () => undefined as unknown as CoreValue); assertEq(escaped === boom, true); - assertEq(h.callee.mayEnter, false); // poisoned: never enterable again + assertEq(isInstancePoisoned(h.callee), true); // never enterable again assertEq(h.callee.mayLeave, false); // left as the trap left it // The caller — the instance that survives and may be re-entered — is sane. - assertEq(h.caller.mayEnter, true); + assertEq(isInstancePoisoned(h.caller), false); assertEq(h.caller.mayLeave, true); assertEq(h.handle.numLends, 0); }); diff --git a/runtime/tests/resource_lifetime_test.ts b/runtime/tests/resource_lifetime_test.ts index 6c64c84..2718e4d 100644 --- a/runtime/tests/resource_lifetime_test.ts +++ b/runtime/tests/resource_lifetime_test.ts @@ -1,7 +1,8 @@ // Resource destructor gating and host-side lend tracking (issues #85, #86). // -// Authority: definitions.py `canon_resource_drop` (line 2318) and the -// `Store.lift` entry gate it routes the dtor through (lines 579-584), plus +// Authority: definitions.py `canon_resource_drop` (@ 2f13265) and the +// `Store.lift` it routes the dtor through — which post-CM#705 carries NO +// entry gate, so dtor reentrance into a live instance is valid — plus // the lend bookkeeping of `Subtask.add_lender` / `deliver_resolve` // (lines 890, 902) and the `num_lends` traps in `lift_own` / // `canon_resource_drop` (lines 1508, 2325). @@ -13,7 +14,11 @@ import { } from "../src/cabi/mod.ts"; import { ComponentInstanceState, Store, storeQuiescent } from "../src/task/mod.ts"; import { driveStoreAsync, hostDtorCall } from "../src/exec/boundary.ts"; -import { setOnInstancePoisoned } from "../src/task/scheduler.ts"; +import { + isInstancePoisoned, + notifyInstancePoisoned, + setOnInstancePoisoned, +} from "../src/task/scheduler.ts"; // Side-effecting import: registers `retireInstanceAsyncEnds` as the poisoning // hook (#66). Without it the seam is null and the poison walk is a no-op. import { retireInstanceAsyncEnds } from "../src/task/streams.ts"; @@ -82,7 +87,10 @@ function mkPair(): { // #85 — dtor gating // --------------------------------------------------------------------------- -Deno.test("#85: dropping a cross-instance own traps while the impl is entered", () => { +Deno.test("#85/#173: dropping a cross-instance own while the impl is LIVE succeeds", () => { + // INVERTED by polyengine#173 (CM#705): `canon_resource_drop` lifts the dtor + // through a `Store.lift` that no longer gates, so a drop whose implementing + // instance is mid-execution is valid and the dtor simply runs. const { caller, impl } = mkPair(); let ran = 0; const rt = new ResourceTypeInfo(impl, () => { @@ -90,54 +98,47 @@ Deno.test("#85: dropping a cross-instance own traps while the impl is entered", }); const h = canonResourceNew(caller, rt, 42); - // The implementing instance is mid-execution (someone is inside it). - impl.enterFrom(null); - assertTrap( - () => canonResourceDrop(caller, rt, h), - "cannot enter component instance", - ); - assertEq(ran, 0); - // The gate was refused, not taken: the impl is still merely *entered*, not - // poisoned, and leaving it restores enterability. - impl.leaveTo(null); - assertEq(impl.mayEnter, true); + void impl; + canonResourceDrop(caller, rt, h); + assertEq(ran, 1, "the dtor ran; nothing was refused"); }); -Deno.test("#85: a dtor-less resource is gated too (the reference's `or lambda`)", () => { +Deno.test("#85/#173: a dtor-less drop into a live impl succeeds too", () => { const { caller, impl } = mkPair(); const rt = new ResourceTypeInfo(impl, null); const h = canonResourceNew(caller, rt, 7); - impl.enterFrom(null); - assertTrap( - () => canonResourceDrop(caller, rt, h), - "cannot enter component instance", - ); + void impl; + canonResourceDrop(caller, rt, h); }); -Deno.test("#85: same-instance drop is exempt even while the instance is entered", () => { - const { caller } = mkPair(); - let ran = 0; - // impl === the dropping instance: `entering_set(caller)` is empty. - const rt = new ResourceTypeInfo(caller, () => { - ran += 1; +Deno.test("#85: a POISONED impl still refuses the drop", () => { + // The surviving refusal: polyengine's per-instance corpse divergence. + withPoisonSpy(() => { + const { caller, impl } = mkPair(); + const rt = new ResourceTypeInfo(impl, () => {}); + const h = canonResourceNew(caller, rt, 43); + notifyInstancePoisoned(impl, new Error("earlier boom")); + assertTrap( + () => canonResourceDrop(caller, rt, h), + "cannot enter component instance", + ); }); - const h = canonResourceNew(caller, rt, 5); - caller.enterFrom(null); // the guest is of course running while it drops - canonResourceDrop(caller, rt, h); - assertEq(ran, 1); - assertEq(caller.mayEnter, false); // unchanged by the drop }); -Deno.test("#85: a cross-instance drop takes and releases the gate", () => { - const { caller, impl } = mkPair(); - const seenInside: boolean[] = []; - const rt = new ResourceTypeInfo(impl, () => { - seenInside.push(impl.mayEnter); +Deno.test("#85: a same-instance drop is admissible even against its own marker", () => { + // `entryRefusal`'s vacuous pass (`caller !== callee`): a guest dropping a + // handle to its OWN resource is never refused by its own poison marker. + withPoisonSpy(() => { + const { caller } = mkPair(); + let ran = 0; + const rt = new ResourceTypeInfo(caller, () => { + ran += 1; + }); + const h = canonResourceNew(caller, rt, 5); + notifyInstancePoisoned(caller, new Error("earlier boom")); + canonResourceDrop(caller, rt, h); + assertEq(ran, 1); }); - const h = canonResourceNew(caller, rt, 1); - canonResourceDrop(caller, rt, h); - assertEq(seenInside, [false]); // entered for the duration - assertEq(impl.mayEnter, true); // and left afterwards }); Deno.test("#85: a trapping dtor poisons the impl instance and retires its ends", () => { @@ -155,13 +156,12 @@ Deno.test("#85: a trapping dtor poisons the impl instance and retires its ends", caught = e; } assertEq(caught === boom, true); - // `leave_to` is not reached: the impl is unenterable forever. - assertEq(impl.mayEnter, false); + // The impl is a corpse; the dropping instance is untouched. + assertEq(isInstancePoisoned(impl), true); + assertEq(isInstancePoisoned(caller), false); assertEq(seen.length, 1); assertEq(seen[0].inst === impl, true); assertEq(seen[0].cause === boom, true); - // ... and the dropping instance is untouched by *this* bracket. - assertEq(caller.mayEnter, true); }); }); @@ -177,21 +177,21 @@ Deno.test("#85: a guest-initiated dtor that does not finish synchronously traps" () => canonResourceDrop(caller, rt, h), "did not complete synchronously", ); - assertEq(impl.mayEnter, false); + assertEq(isInstancePoisoned(impl), true); assertEq(seen.length, 1); }); }); -Deno.test("#160: a host-initiated async dtor does NOT hold the gate", async () => { +Deno.test("#160: a host-initiated async dtor is not external work", async () => { // REVISED from the #85 pin "holds the gate until it settles". That // behaviour was the bug: the held `enterFrom(null)` bracket made the impl // instance non-enterable for the whole activation, so `Store.tick`'s // enterability filter could never resume a suspension point belonging to - // the dtor itself (#160). A host-initiated dtor is now a full canonical - // lift (definitions.py `canon_resource_drop` line 2319), whose bracket is - // released at the first park — so the instance is host-enterable while the - // dtor is in flight, and the completion promise is NOT a `pendingHostCalls` - // entry (it is not external work). + // the dtor itself (#160). A host-initiated dtor is a full canonical lift + // (definitions.py `canon_resource_drop`), and post-CM#705 (polyengine#173) + // there is no gate left to hold at all. What still needs pinning: the + // completion promise is NOT a `pendingHostCalls` entry (it is not external + // work), and the store drains cleanly. const { store, impl } = mkPair(); let resolveDtor: () => void = () => {}; const rt = new ResourceTypeInfo( @@ -201,12 +201,11 @@ Deno.test("#160: a host-initiated async dtor does NOT hold the gate", async () = ) => void, ); hostDtorCall(rt, 11); - assertEq(impl.mayEnter, true); - assertEq(impl.mayEnterFrom(null), true); + assertEq(isInstancePoisoned(impl), false); assertEq(store.pendingHostCalls.size, 0); resolveDtor(); await driveStoreAsync(store, () => storeQuiescent(store), "dtor drain"); - assertEq(impl.mayEnter, true); + assertEq(isInstancePoisoned(impl), false); assertEq(store.pendingHostCalls.size, 0); assertEq(store.hostFailure, undefined); }); @@ -229,7 +228,7 @@ Deno.test("#85/#160: a rejected host-initiated dtor poisons and lands on hostFai .catch(() => {}); await Promise.resolve(); assertEq(store.hostFailure === boom, true); - assertEq(impl.mayEnter, false); + assertEq(isInstancePoisoned(impl), true); assertEq(seen.length, 1); store.hostFailure = undefined; }); @@ -319,7 +318,7 @@ Deno.test("#86: a trapping backstop dtor poisons the impl and records the failur simulateFinalizationForTest(w); // ... but is no longer swallowed either (the former `catch {}`). assertEq(store.hostFailure === boom, true); - assertEq(impl.mayEnter, false); + assertEq(isInstancePoisoned(impl), true); assertEq(seen.length, 1); store.hostFailure = undefined; }); diff --git a/runtime/tests/settled_deferral_test.ts b/runtime/tests/settled_deferral_test.ts index fd5eead..79f57b8 100644 --- a/runtime/tests/settled_deferral_test.ts +++ b/runtime/tests/settled_deferral_test.ts @@ -1,22 +1,17 @@ -// Driver-level coverage for issue #156: a settled activation tail whose -// instance is not host-enterable is DEFERRED IN PLACE, and `driveAsync` must -// park (not spin) until the lock releases. +// Driver-level coverage for issue #156, INVERTED by polyengine#173 (CM#705). // -// Shape manufactured below — the reachable one from the issue's analysis: +// #156's shape: instance B's thread parked on an already-settled +// `awaitValue` (tail queued in `store.settled`) while sibling instance A held +// a host entry, which under the shared synthetic per-instantiation root made +// B non-enterable — so B's tail was DEFERRED IN PLACE and `driveAsync` had to +// park (not spin) until the lock released. // -// * instance B's thread is parked on an `awaitValue` promise that has -// already settled, so its tail sits in `store.settled`; -// * sibling instance A is entered from the host (`enterFrom(null)`), which -// under the shared synthetic per-instantiation root locks B too; -// * the only way out is an outstanding host call whose settle releases the -// lock — the async-dtor bracket's shape, registered in -// `store.pendingHostCalls` with its `.then` attached BEFORE insertion, -// mirroring `callDtorGated`. -// -// Pre-fix this either crashed (`resumeWith`'s enterability assert, reached -// through `serviceSettled`) or spun the driver hot with no await in the -// cycle. The host promise resolves from a `setTimeout(0)`, so passing -// requires the loop to genuinely park across a macrotask. +// Deferral can no longer occur: definitions.py @ 2f13265 has no +// `may_enter`/`enter_from`/`leave_to`, the runtime takes no bracket anywhere, +// and `dispatchableTail` is constant-true for a live instance. The pin below +// is the merged behavior — the tail dispatches immediately, with an unrelated +// outstanding host call in flight, and the driver still reaches quiescence +// (the outstanding call must not be mistaken for a reason to wedge). import { assertEq } from "./support/asserts.ts"; import { driveStoreAsync } from "../src/exec/boundary.ts"; @@ -53,7 +48,7 @@ function spawn( return thread; } -Deno.test("driveAsync: a deferred tail parks the loop until the host entry leaves", async () => { +Deno.test("driveAsync: a sibling's tail dispatches immediately (CM#705)", async () => { const store = new Store(); const a = new ComponentInstanceState(0, store); const b = new ComponentInstanceState(1, store); @@ -76,23 +71,21 @@ Deno.test("driveAsync: a deferred tail parks the loop until the host entry leave await Promise.resolve(); assertEq(store.settled.length, 1, "B's tail is queued"); - // A holds a host entry, which locks the shared root (and therefore B). - a.enterFrom(null); - assertEq(b.mayEnterFrom(null), false); + // A is mid-host-entry. Post-CM#705 that constrains nothing about B. + void a; - // The outstanding host call whose settle releases the lock. `.then` is - // registered BEFORE insertion, as `callDtorGated` does, so the driver's - // race sees an entry that self-removes. + // An outstanding host call, registered exactly as `callDtorGated` did + // (`.then` attached BEFORE insertion, so the driver's race sees an entry + // that self-removes). Demonstrably a macrotask away: the driver must park, + // not spin, while it is outstanding. let releaseHostCall!: () => void; const hostCall = new Promise((r) => { releaseHostCall = r; }); const gated = hostCall.then(() => { - a.leaveTo(null); store.pendingHostCalls.delete(gated); }); store.pendingHostCalls.add(gated); - // Demonstrably a macrotask away: the driver must park, not spin. setTimeout(() => releaseHostCall(), 0); await driveStoreAsync( diff --git a/runtime/tests/streams_teardown_test.ts b/runtime/tests/streams_teardown_test.ts index 1419261..c467f6a 100644 --- a/runtime/tests/streams_teardown_test.ts +++ b/runtime/tests/streams_teardown_test.ts @@ -44,6 +44,7 @@ import { ComponentInstanceState, CopyResult, CopyState, + isInstancePoisoned, notifyInstancePoisoned, popCurrentThread, pushCurrentThread, @@ -152,9 +153,8 @@ function mkFutureSplit(mode: "plain" | "jspi" = "plain") { thread, read: createFutureRead({ futureTable: 0, options: 0 }, ctx, reader), wait: createWaitableSetWait({ options: 0 }, ctx, reader), - /** Model the broken enter/leave bracket the walk is always called under. */ + /** Model the trap the walk is always called under. */ poison(cause: unknown) { - writer.mayEnter = false; retireInstanceAsyncEnds(writer, cause); }, run(fn: () => T): T { @@ -218,10 +218,10 @@ Deno.test("#84(a): a parked async future reader traps when the writer's instance // The reader parks: async `future.read` with nobody on the other side. assertEq(f.run(() => f.read(f.ri, 0)), BLOCKED); assertEq(f.readEnd.state, CopyState.COPYING); - // Healthy: the reader's instance is enterable while it is parked. This is + // Healthy: the reader's instance is unmarked while it is parked. This is // the property `dropSharedForTeardown`'s notify/silently-retire test rests // on (see its #84 AUDIT note). - assertEq(f.reader.mayEnter, true); + assertEq(isInstancePoisoned(f.reader), false); const boom = new Trap("unreachable"); f.poison(boom); @@ -276,7 +276,6 @@ Deno.test("#84(a'): a stream reader keeps the spec-shaped DROPPED outcome", () = } finally { popCurrentThread(thread); } - writer.mayEnter = false; retireInstanceAsyncEnds(writer, new Trap("unreachable")); assertEq(readEnd.hasPendingEvent(), true); const [, , payload] = readEnd.getPendingEvent(); @@ -298,10 +297,9 @@ Deno.test("#84(b): a JSPI-blocked future reader's suspension rejects with the tr const parked = f.run(() => read(f.ri, 0)) as unknown as Promise; assertEq(f.readEnd.state, CopyState.COPYING); assertEq(f.readEnd.hasSyncWaiter, true); - // A JSPI-blocked peer parks OUTSIDE the enter bracket, exactly like a - // callback-ABI one: blocking inside the wasm frame does not hold `mayEnter` - // (#84 audit item; see `dropSharedForTeardown`). - assertEq(f.reader.mayEnter, true); + // A JSPI-blocked peer is healthy while parked, exactly like a callback-ABI + // one (#84 audit item; see `dropSharedForTeardown`). + assertEq(isInstancePoisoned(f.reader), false); f.poison(new Trap("unreachable")); // The scheduler resumes the suspension point; `produce` throws, which the @@ -372,7 +370,6 @@ Deno.test("#84: one end's failing notification does not strand the remaining end const okFuture = new SharedFutureImpl(null); inst.handles.add(new WritableFutureEnd(okFuture)); - inst.mayEnter = false; const raised = caughtSync(() => retireInstanceAsyncEnds(inst, new Trap("x"))); // The first failure is rethrown... @@ -529,22 +526,22 @@ Deno.test("#97: cancelRead resolves the read exactly like end-of-stream does", a }); // --------------------------------------------------------------------------- -// #100: `mayEnter === false` is not "poisoned" — a mid-FACT-call CALLER's -// healthy parked task must not be silently retired +// #100: "poisoned" is a MARKER, not a liveness proxy — a mid-FACT-call +// CALLER's healthy parked task must not be silently retired // --------------------------------------------------------------------------- // // The stranding shape from the issue. Instance A (caller) is mid -// cross-component (FACT) call into instance B (callee), so A is non-enterable -// for the whole duration of that call (`enterFrom` clears `mayEnter` on the -// callee's entering set; the caller's own bracket is still open). A DIFFERENT, +// cross-component (FACT) call into instance B (callee). A DIFFERENT, // perfectly healthy task of A is parked on an end of a stream/future whose // peer end B holds. B traps; the poisoning walk runs over B's table and // reaches A's parked side. The old health test (`mayEnter === false`) read A -// as a corpse and retired it in silence — stranded, the outcome #66 exists to -// prevent. The narrowed test (task/scheduler.ts's per-instance poison marker, -// recorded at the `notifyInstancePoisoned` seam) gives A the spec-shaped -// outcome instead: DROPPED for a stream, the #84 abandonment trap for an -// unwritten future. +// as a corpse — A was non-enterable merely because it was mid-call — and +// retired it in silence: stranded, the outcome #66 exists to prevent. The +// narrowed test (task/scheduler.ts's per-instance poison marker, recorded at +// the `notifyInstancePoisoned` seam) gives A the spec-shaped outcome instead: +// DROPPED for a stream, the #84 abandonment trap for an unwritten future. +// CM#705 (polyengine#173) has since deleted `may_enter` outright, so the +// marker is not merely the better predicate but the only one available. // // Both directions are pinned here: the dead-guest discipline (a parked task of // the POISONED instance itself is still silently retired) has its own leg @@ -590,17 +587,18 @@ function inTask(inst: ComponentInstanceState, fn: () => T): T { } /** - * Put `caller` mid-cross-component-call into `callee`: the host entered the - * caller, and the caller entered the callee (task/mod.ts `enterFrom`). + * `caller` is mid-cross-component-call into `callee`. Post-CM#705 that state + * carries no instance-level flag at all (the pre-#705 `enterFrom` chain, whose + * cleared `may_enter` on BOTH instances is what the old health test tripped + * over, no longer exists), so this is documentation: neither instance is + * marked, and only the marker decides. */ function enterMidFactCall( caller: ComponentInstanceState, callee: ComponentInstanceState, ): void { - caller.enterFrom(null); - callee.enterFrom(caller); - assertEq(caller.mayEnter, false); // the trap the old health test fell into - assertEq(callee.mayEnter, false); + assertEq(isInstancePoisoned(caller), false); + assertEq(isInstancePoisoned(callee), false); } Deno.test("#100: a mid-FACT-call caller's parked stream reader gets DROPPED, not silence", () => { @@ -613,7 +611,7 @@ Deno.test("#100: a mid-FACT-call caller's parked stream reader gets DROPPED, not const readEnd = new ReadableStreamEnd(shared); const ri = caller.handles.add(readEnd); - // A's other task parks on the read (a healthy park: `mayEnter === true`). + // A's other task parks on the read — a healthy park. const read = createStreamRead( { streamTable: 0, options: 0 }, mkCtx(caller, view), @@ -621,10 +619,9 @@ Deno.test("#100: a mid-FACT-call caller's parked stream reader gets DROPPED, not ); assertEq(inTask(caller, () => read(ri, 0, 4)), BLOCKED); assertEq(readEnd.state, CopyState.COPYING); - assertEq(caller.mayEnter, true); // A calls into B; B traps. The poison goes through the one seam every - // bracket-break site uses, which is what records the marker. + // poisoning site uses, which is what records the marker. enterMidFactCall(caller, callee); notifyInstancePoisoned(callee, new Trap("unreachable")); @@ -653,7 +650,6 @@ Deno.test("#100: a mid-FACT-call caller's parked future reader gets the abandonm readEnd.join(wset); assertEq(inTask(caller, () => read(ri, 0)), BLOCKED); - assertEq(caller.mayEnter, true); enterMidFactCall(caller, callee); const boom = new Trap("unreachable"); @@ -697,8 +693,7 @@ Deno.test("#100: the poisoned instance's OWN parked task is still retired silent Deno.test("#100: a host peer parked on a poisoned guest's end is still notified", () => { // Host sentinels are not component instances, so the predicate never reads - // them as poisoned (the pre-#100 test relied on the absence of a `mayEnter` - // key for the same conclusion). + // them as poisoned. const store = new Store(); const guest = new ComponentInstanceState(0, store); const shared = new SharedStreamImpl(null); diff --git a/runtime/tests/task_test.ts b/runtime/tests/task_test.ts index ebcd622..1c01a04 100644 --- a/runtime/tests/task_test.ts +++ b/runtime/tests/task_test.ts @@ -13,6 +13,7 @@ import { type Cancelled, chooseCandidate, ComponentInstanceState, + dispatchableTail, driveSyncLift, entryRefusal, EventCode, @@ -195,8 +196,18 @@ Deno.test("canon_lift sync loop: traps when no thread can make progress", () => }); // --------------------------------------------------------------------------- -// Reentrance (ComponentInstance.enter_from / may_enter_from) +// The RETAINED-BUT-INERT reentrance model (ComponentInstance.enter_from / +// may_enter_from) // --------------------------------------------------------------------------- +// +// CM#705 (definitions.py @ 2f13265) removed `may_enter`, `entering_set`, +// `enter_from`, `leave_to` and `ComponentInstance.parent` outright, and +// polyengine#173 removed every CALL to them from the runtime: nothing gates +// entry any more except the poison marker. The model's own arithmetic is +// still defined in task/mod.ts pending the contract amendment that deletes +// it, and these tests pin that arithmetic so the deletion PR is a clean +// subtraction. They assert NOTHING about runtime behavior — no runtime path +// consults them. Deno.test("reentrance: an instance cannot be re-entered from the host", () => { const inst = new ComponentInstanceState(0); @@ -259,28 +270,30 @@ Deno.test("root: guest-to-guest entering sets are unchanged ({leaf})", () => { assertEq([...a.enteringSet(a)].length, 0); }); -Deno.test("root: tick skips a sibling whose instance is locked by a host entry", () => { - // Regression: `tick` used to select on `ready()` alone and then *assert* - // host-enterability, so this shape trapped instead of making no progress. +Deno.test("root: tick resumes a ready sibling thread during a live host entry", () => { + // INVERTED by polyengine#173 (CM#705). This shape used to be the #155 + // regression: `tick` filtered its candidates on host-enterability, and + // under the shared synthetic root a host entry into A made every sibling + // non-enterable, so B could not run until A's call returned. + // + // The merged reference (definitions.py @ 2f13265 `Store.tick`) resumes any + // ready thread with no gate and no bracket, so B runs immediately. // - // Shape: instance A is entered from the host by a sync export that is parked - // on an async host import (the import settles from host JS, not from - // `tick`). While A is parked, a thread of sibling instance B goes ready — - // event-driven wakeups do this on every clock turn. A's host entry locks the - // shared synthetic root, so B is not host-enterable in that window. + // Shape: instance A is entered from the host by a sync export parked on an + // async host import (the import settles from host JS, not from `tick`). + // While A is parked, a thread of sibling instance B goes ready. const store = new Store(); const a = new ComponentInstanceState(0, store); const b = new ComponentInstanceState(1, store); + void a; - // A's export call: entered from the host, parked awaiting an async import. - a.enterFrom(null); let settleImport!: () => void; const pendingImport = new Promise((resolve) => { settleImport = resolve; }); store.pendingHostCalls.add(pendingImport); - // B's thread: waiting, and it becomes ready while A is still entered. + // B's thread: waiting, and it becomes ready while A's host entry is live. let flag = false; const order: string[] = []; const bTask = mkTask(b, SYNC_FT, SYNC_OPTS); @@ -297,33 +310,28 @@ Deno.test("root: tick skips a sibling whose instance is locked by a host entry", flag = true; assertEq(bThread.ready(), true, "B is ready..."); - assertEq(b.mayEnterFrom(null), false, "...but locked through the shared root"); - // The window: no throw, and no progress on B. - assertEq(store.tick(), false, "ready-but-not-enterable is not progress"); - assertEq(order.length, 0); - assertEq(bThread.done(), false); - - // The host import settles and A's entered call returns. - settleImport(); - store.pendingHostCalls.delete(pendingImport); - a.leaveTo(null); - assertEq(b.mayEnterFrom(null), true, "the root is unlocked again"); - - // B now runs to completion on the next turn. - assertEq(store.tick(), true); + assertEq(store.tick(), true, "...and ready is now sufficient"); assertEq(order.join(","), "b ran"); assertEq(bTask.state, "resolved"); assertEq(store.waiting.length, 0); + + settleImport(); + store.pendingHostCalls.delete(pendingImport); }); -// --- issue #156: settled activation tails defer while the root is locked ---- +// --- issue #156: settled activation tails, post-CM#705 --------------------- // -// `Store.settled` tails are dispatched through `Thread.resumeWith`, which -// brackets the resumption with `enterFrom(null)`. Under the shared synthetic -// root a host entry into ANY instance locks every sibling, so dispatching a -// sibling's tail in that window used to trip `resumeWith`'s enterability -// assert (and, mutating before asserting, strand the thread and lose the -// settle). The fix defers such tails IN PLACE. +// History: `Store.settled` tails are dispatched through `Thread.resumeWith`, +// which used to bracket the resumption with `enterFrom(null)`. Under the +// shared synthetic root a host entry into ANY instance locked every sibling, +// so dispatching a sibling's tail in that window tripped `resumeWith`'s +// enterability assert (and, mutating before asserting, stranded the thread +// and lost the settle); #156 deferred such tails IN PLACE. +// +// INVERTED by polyengine#173: there is no bracket and nothing is ever +// non-enterable, so every non-stale tail dispatches immediately. What still +// holds — and is pinned below — is the settle-order discipline (a +// serviceable tail gates `tick`) and the poisoned-tail retirement (#66). /** Settle a park promise and let `noteAwaiting`'s eager continuation run. */ async function queueSettledTail(settle: () => void): Promise { @@ -333,7 +341,7 @@ async function queueSettledTail(settle: () => void): Promise { await Promise.resolve(); } -Deno.test("root: serviceSettled defers a sibling tail while a host entry holds the root", async () => { +Deno.test("root: serviceSettled dispatches a sibling tail immediately", async () => { const store = new Store(); const a = new ComponentInstanceState(0, store); const b = new ComponentInstanceState(1, store); @@ -359,20 +367,14 @@ Deno.test("root: serviceSettled defers a sibling tail while a host entry holds t await queueSettledTail(settle); assertEq(store.settled.length, 1, "the tail is queued"); - // A host entry into the sibling locks the shared root. - a.enterFrom(null); - assertEq(b.mayEnterFrom(null), false); - assertEq(store.serviceSettled(), false, "deferred: no dispatch, no throw"); - assertEq(store.settled.length, 1, "the entry stays queued, in place"); - assertEq(store.awaiting.has(bThread), true, "and the thread is not stranded"); - assertEq(order.length, 0); - assertEq(store.tick(), false, "a deferred-only queue does not gate tick open"); - - a.leaveTo(null); - assertEq(store.serviceSettled(), true, "the lock released: dispatch"); + // A live host entry into the sibling used to defer this tail (#156). Post + // CM#705 nothing defers: the tail dispatches on the spot. + void a; + assertEq(store.serviceSettled(), true, "dispatched, not deferred"); assertEq(order.join(","), "b tail ran"); assertEq(bTask.state, "resolved"); assertEq(store.settled.length, 0); + assertEq(store.awaiting.has(bThread), false); }); Deno.test("root: the phantom-state gate holds for a serviceable tail", async () => { @@ -415,7 +417,7 @@ Deno.test("root: the phantom-state gate holds for a serviceable tail", async () await queueSettledTail(settle); assertEq(store.settled.length, 1); - assertEq(a.mayEnterFrom(null), true, "nothing is entered: the tail is serviceable"); + assertEq(dispatchableTail(bThread), true, "the tail is serviceable"); assertEq(store.tick(), false, "a serviceable tail gates tick"); assertEq(order.length, 0); @@ -424,10 +426,10 @@ Deno.test("root: the phantom-state gate holds for a serviceable tail", async () assertEq(order.join(","), "a ran"); }); -Deno.test("root: poisoned tails retire even while the root is locked", async () => { - // A poisoned leaf stays locked forever, so deferring its tail would leak. - // `resumeWith`'s poison early-return retires it instead: the queue drains - // and the body does NOT run. +Deno.test("root: a poisoned instance's tail retires without running", async () => { + // `resumeWith`'s poison early-return retires the tail: the queue drains and + // the body does NOT run. (#66 / #156; unchanged by CM#705 — a corpse's + // parked segments must never resume.) const store = new Store(); const a = new ComponentInstanceState(0, store); const b = new ComponentInstanceState(1, store); @@ -451,14 +453,13 @@ Deno.test("root: poisoned tails retire even while the root is locked", async () assertEq(store.settled.length, 1); notifyInstancePoisoned(b, undefined); - a.enterFrom(null); - assertEq(store.serviceSettled(), true, "poisoned tails dispatch, locked or not"); + void a; + assertEq(store.serviceSettled(), true, "poisoned tails dispatch"); assertEq(store.settled.length, 0, "and the queue drains"); assertEq(order.length, 0, "retired quietly: the body never ran"); - a.leaveTo(null); }); -Deno.test("root: stale settled entries are removed regardless of enterability", async () => { +Deno.test("root: stale settled entries are removed", async () => { // "Stale" = the thread was resumed elsewhere (driveAsync's race-winner // path), i.e. it is gone from `store.awaiting`. Such entries are dropped // whenever encountered, and dropping one is not progress. @@ -484,23 +485,22 @@ Deno.test("root: stale settled entries are removed regardless of enterability", // Simulate the elsewhere-resumption. store.awaiting.delete(bThread); - a.enterFrom(null); + void a; assertEq(store.serviceSettled(), false, "removing a stale entry is not progress"); assertEq(store.settled.length, 0, "but it is removed"); - a.leaveTo(null); }); -Deno.test("root: trap poisoning stays per-instance (documented divergence)", () => { +Deno.test("root: trap poisoning stays per-instance (named divergence)", () => { const store = new Store(); const a = new ComponentInstanceState(0, store); const b = new ComponentInstanceState(1, store); - // A host entry into A that traps never reaches `leaveTo`: A stays poisoned - // forever. polyengine deliberately keeps sibling instances usable (exec/ - // boundary.ts `poison`), so the shared root is released explicitly. - a.enterFrom(null); - a.releaseSyntheticRootOnPoison(); - assertEq(a.mayEnterFrom(null), false, "the leaf stays poisoned"); - assertEq(b.mayEnterFrom(null), true, "a sibling is still enterable"); + // polyengine buries only the instance that trapped, where wasmtime kills + // the whole store (exec/boundary.ts `poison`). Since polyengine#173 the + // marker is the entire mechanism, so "per-instance" is a property of the + // marker alone. + notifyInstancePoisoned(a, new Trap("boom")); + assertEq(entryRefusal(a, null, "base") !== null, true, "A is a corpse"); + assertEq(entryRefusal(b, null, "base"), null, "a sibling is still enterable"); }); // --------------------------------------------------------------------------- @@ -813,10 +813,11 @@ Deno.test("cancellation: with no cancellable thread it becomes pending", () => { }); Deno.test("tick: a trap under tick records the poison marker", async () => { - // A trap escaping `thread.resume()` under `Store.tick` breaks the - // enter/leave bracket (definitions.py `Store.tick`, line 597) — and must - // also record the poison MARKER, which `Thread.resumeWith`'s quiet-retire - // and `dispatchableTail` read (polyengine#145, #156). + // A trap escaping `thread.resume()` under `Store.tick` poisons the + // instance. Post-CM#705 there is no bracket to break, so recording the + // MARKER is the entire act — and it is what `Thread.resumeWith`'s + // quiet-retire, `dispatchableTail` and `entryRefusal` all read + // (polyengine#145, #156, #251). const store = new Store(); const b = new ComponentInstanceState(0, store); @@ -854,7 +855,11 @@ Deno.test("tick: a trap under tick records the poison marker", async () => { assertThrows(() => store.tick(), "boom under tick"); assertEq(isInstancePoisoned(b), true, "the poison marker is recorded"); - assertEq(b.mayEnterFrom(null), false, "and the bracket stays broken"); + assertEq( + entryRefusal(b, null, "base") !== null, + true, + "and the marker is what refuses later entry", + ); assert( withPoisonCause(b, "x").includes("boom under tick"), "the cause is available for entry-refusal diagnostics", @@ -871,8 +876,10 @@ Deno.test("tick: a trap under tick records the poison marker", async () => { }); Deno.test("request_cancellation: a trap during delivery poisons the callee", () => { - // definitions.py `Task.request_cancellation` (lines 519-532) wraps the - // delivery `resume(Cancelled.TRUE)` in no handler: a Trap skips `leave_to`. + // definitions.py `Task.request_cancellation` (@ 2f13265) wraps the delivery + // `resume(Cancelled.TRUE)` in no handler at all. polyengine records the + // trap as per-instance poisoning instead of ending the world + // (polyengine#164/#212). const store = new Store(); const callerInst = new ComponentInstanceState(0, store); const b = new ComponentInstanceState(1, store); @@ -890,14 +897,13 @@ Deno.test("request_cancellation: a trap during delivery poisons the callee", () () => task.requestCancellation(callerInst), "boom during cancel delivery", ); - assertEq(b.mayEnterFrom(callerInst), false, "the callee stays locked"); - assertEq(isInstancePoisoned(b), true); + assertEq(isInstancePoisoned(b), true, "the callee is a corpse"); assertEq(task.state, "cancel-delivered", "parity: the state is set first"); }); -Deno.test("request_cancellation: a capability signal releases the gate", () => { +Deno.test("request_cancellation: a capability signal does not poison", () => { // Capability signals mark the RUNTIME incomplete, not the component - // faulted: the bracket is released, exactly as in `Store.tick`. + // faulted: nothing is poisoned, exactly as in `Store.tick`. const store = new Store(); const callerInst = new ComponentInstanceState(0, store); const b = new ComponentInstanceState(1, store); @@ -914,31 +920,23 @@ Deno.test("request_cancellation: a capability signal releases the gate", () => { () => task.requestCancellation(callerInst), "pending-capability: x", ); - assertEq(b.mayEnterFrom(callerInst), true, "the gate is released"); - assertEq(isInstancePoisoned(b), false, "and nothing is poisoned"); + assertEq(isInstancePoisoned(b), false, "nothing is poisoned"); }); // --------------------------------------------------------------------------- // Poisoning re-key (polyengine#173) // --------------------------------------------------------------------------- // -// White-box pins on the property the re-key buys: every poisoning DECISION -// reads the poison MARKER, not `mayEnter`. Each test below forces `mayEnter` -// back to true on a marked instance — a state the runtime never produces -// today — so the pin isolates the marker's authority and must survive the -// CM#705 deletion of `may_enter` unchanged. - -/** Force a marked instance (and its synthetic root) back to enterable. */ -function forceEnterable(inst: ComponentInstanceState): void { - for (const i of inst.selfAndAncestors()) i.mayEnter = true; -} +// White-box pins on the property the re-key bought and CM#705 then made +// unavoidable: every entry-refusal DECISION reads the poison MARKER, which is +// now the only refusal mechanism there is. Nothing locks an instance any +// more, so these tests need no `mayEnter` manipulation — an unmarked instance +// is always enterable, by construction. -Deno.test("re-key: entryRefusal refuses a marked instance with mayEnter forced true", () => { +Deno.test("re-key: entryRefusal refuses a marked instance", () => { const store = new Store(); const inst = new ComponentInstanceState(0, store); notifyInstancePoisoned(inst, new Trap("boom")); - forceEnterable(inst); - assertEq(inst.mayEnterFrom(null), true, "the transient gate is wide open"); const r = entryRefusal(inst, null, "cannot enter component instance"); assert(r !== null, "the marker alone refuses entry"); @@ -947,36 +945,28 @@ Deno.test("re-key: entryRefusal refuses a marked instance with mayEnter forced t assertEq(r.includes("boom"), true, "and it is the original trap"); }); -Deno.test("re-key: an unmarked but bracket-locked instance refuses with the bare base", () => { - const store = new Store(); - const inst = new ComponentInstanceState(0, store); - inst.enterFrom(null); - assertEq( - entryRefusal(inst, null, "cannot enter component instance"), - "cannot enter component instance", - "transient reentrance: byte-identical to the pre-re-key message", - ); -}); - -Deno.test("re-key: an enterable instance is not refused", () => { +Deno.test("re-key: an unmarked instance is never refused (CM#705)", () => { + // The clause that returned the bare base for a transiently-locked instance + // is DELETED with polyengine#173: entry into a live instance is valid. const store = new Store(); const inst = new ComponentInstanceState(0, store); assertEq(entryRefusal(inst, null, "base"), null); + const other = new ComponentInstanceState(1, store); + assertEq(entryRefusal(inst, other, "base"), null, "and from a guest caller"); }); Deno.test("re-key: caller === callee passes vacuously even when marked", () => { - // definitions.py `entering_set` (line 230) is empty for a self-call, so - // there is no instance to check. The dtor path (cabi/handles.ts) relies on - // this: a guest dropping its own resource is not refused by its own marker. + // The pre-#705 `entering_set` was empty for a self-call, so there was no + // instance to check; `entryRefusal` keeps that vacuous pass. The dtor path + // (cabi/handles.ts) relies on it: a guest dropping its own resource is not + // refused by its own marker. const store = new Store(); const inst = new ComponentInstanceState(0, store); notifyInstancePoisoned(inst, new Trap("boom")); - forceEnterable(inst); - assertEq([...inst.enteringSet(inst)].length, 0, "empty entering set"); assertEq(entryRefusal(inst, inst, "base"), null); }); -Deno.test("re-key: tick does not resume a marked instance with mayEnter forced true", () => { +Deno.test("re-key: tick does not resume a marked instance", () => { const store = new Store(); const inst = new ComponentInstanceState(0, store); let flag = false; @@ -995,8 +985,6 @@ Deno.test("re-key: tick does not resume a marked instance with mayEnter forced t assertEq(thread.ready(), true, "the thread is ready..."); notifyInstancePoisoned(inst, new Trap("boom")); - forceEnterable(inst); - assertEq(inst.mayEnterFrom(null), true, "...and transiently enterable"); assertEq(store.tick(), false, "but the marker excludes it from tick"); assertEq(order.length, 0); }); @@ -1021,8 +1009,6 @@ Deno.test("re-key: requestCancellation leaves a marked callee's request pending" assertEq(task.state, "started"); notifyInstancePoisoned(b, new Trap("boom")); - forceEnterable(b); - assertEq(b.mayEnterFrom(callerInst), true, "transiently enterable"); task.requestCancellation(callerInst); assertEq(sawCancel, false, "delivery is refused by the marker");