From e7fca36560a07621b10c490c6921d07e96b99295 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Sun, 30 Aug 2026 16:56:33 -0400 Subject: [PATCH] task core: re-key poisoning refusal onto the poison marker (#173) Poisoning decisions now read the poisonedInstances marker, not the permanently-false mayEnter it shadows. New entryRefusal helper owns the entry-refusal decision (marker first with the #145 cause suffix, then the transient may_enter gate, byte-identical messages); the five trap gates (host entry, FACT sync/async start-call, enter-sync-call, dtor) route through it, and the two non-trap decisions (requestCancellation delivery, tick's candidate filter) take the marker as an added conjunct. The caller-is-callee vacuous pass of entering_set is preserved and pinned. Behavior-preserving under the current pin; the point is CM#705 (merged upstream): when the pin advance deletes may_enter, poisoning survives untouched because it no longer depends on it. Six white-box pins force mayEnter back to true on a marked instance to isolate the marker's authority; they must survive the removal unchanged. --- runtime/src/cabi/handles.ts | 14 +++- runtime/src/exec/boundary.ts | 10 ++- runtime/src/intrinsics/fact_calls.ts | 18 +++-- runtime/src/intrinsics/mod.ts | 15 ++-- runtime/src/task/mod.ts | 10 ++- runtime/src/task/scheduler.ts | 53 ++++++++++++- runtime/tests/task_test.ts | 112 +++++++++++++++++++++++++++ 7 files changed, 209 insertions(+), 23 deletions(-) diff --git a/runtime/src/cabi/handles.ts b/runtime/src/cabi/handles.ts index 6352fe3..8205d26 100644 --- a/runtime/src/cabi/handles.ts +++ b/runtime/src/cabi/handles.ts @@ -17,7 +17,7 @@ import { NeedsJspi, notifyInstancePoisoned, PendingCapability, - withPoisonCause, + entryRefusal, } from "../task/scheduler.ts"; import type { ComponentInstanceLike, @@ -259,8 +259,16 @@ export function callDtorGated( const callerInst = asGate(caller) === null ? null : caller; // A poisoned target's refusal names the original trap (polyengine#145). - if (!impl.mayEnterFrom(callerInst)) { - trap(withPoisonCause(impl, "cannot enter component instance")); + // `callerInst` can legitimately BE `impl` here (a guest dropping its own + // resource): `entryRefusal`'s vacuous-pass guard keeps that entry allowed + // even against a marked instance, matching the empty entering set. + { + const refusal = entryRefusal( + impl, + callerInst, + "cannot enter component instance", + ); + if (refusal !== null) trap(refusal); } impl.enterFrom(callerInst); diff --git a/runtime/src/exec/boundary.ts b/runtime/src/exec/boundary.ts index d756ef8..8e6ee48 100644 --- a/runtime/src/exec/boundary.ts +++ b/runtime/src/exec/boundary.ts @@ -50,7 +50,7 @@ import { Task, type TaskOptions, Thread, - withPoisonCause, + entryRefusal, } from "../task/mod.ts"; import { currentTask } from "../task/scheduler.ts"; import { PlanError } from "../plan/loader.ts"; @@ -1376,11 +1376,13 @@ export function createLiftedFunction(input: { // 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). - if (!inst.mayEnterFrom(null)) { - trap(withPoisonCause( + { + const refusal = entryRefusal( inst, + null, `cannot enter component instance ${inst.index} (reentrance forbidden)`, - )); + ); + 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 diff --git a/runtime/src/intrinsics/fact_calls.ts b/runtime/src/intrinsics/fact_calls.ts index f1d55f5..f886eb2 100644 --- a/runtime/src/intrinsics/fact_calls.ts +++ b/runtime/src/intrinsics/fact_calls.ts @@ -85,7 +85,7 @@ import { Task, type TaskOptions, Thread, - withPoisonCause, + entryRefusal, } from "../task/mod.ts"; import { blockCurrentActivation, enterWasm } from "../jspi/mod.ts"; import { @@ -658,11 +658,13 @@ export function createSyncStartCall( // 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). - if (!prepared.calleeInst.mayEnterFrom(prepared.callerInst)) { - trap(withPoisonCause( + { + const refusal = entryRefusal( prepared.calleeInst, + prepared.callerInst, "cannot enter component instance", - )); + ); + if (refusal !== null) trap(refusal); } prepared.calleeInst.enterFrom(prepared.callerInst); let ok = false; @@ -902,11 +904,13 @@ export function createAsyncStartCall( subtask.calleeTask = task; // A poisoned callee's refusal names the original trap (polyengine#145). - if (!prepared.calleeInst.mayEnterFrom(prepared.callerInst)) { - trap(withPoisonCause( + { + const refusal = entryRefusal( prepared.calleeInst, + prepared.callerInst, "cannot enter component instance", - )); + ); + if (refusal !== null) trap(refusal); } prepared.calleeInst.enterFrom(prepared.callerInst); let ok = false; diff --git a/runtime/src/intrinsics/mod.ts b/runtime/src/intrinsics/mod.ts index aea8909..108fd77 100644 --- a/runtime/src/intrinsics/mod.ts +++ b/runtime/src/intrinsics/mod.ts @@ -25,7 +25,7 @@ import { trapIf } from "../cabi/trap.ts"; import { assert_ } from "../cabi/trap.ts"; import type { ResourceTypeInfo } from "../cabi/types.ts"; import type { ComponentInstanceState } from "../task/mod.ts"; -import { maybeCurrentThread, maybeCurrentTask, PendingCapability, withPoisonCause } from "../task/mod.ts"; +import { entryRefusal, maybeCurrentThread, maybeCurrentTask, PendingCapability } from "../task/mod.ts"; import type { WireTrampoline } from "../plan/format.ts"; import type { CoreFn, ExecutionStats } from "../exec/boundary.ts"; import { UnsupportedFeatureError } from "./errors.ts"; @@ -535,12 +535,13 @@ function createTrampolineBody( const callerInst = ctx.componentInstance(callerInstance >>> 0); const calleeInst = ctx.componentInstance(calleeInstance >>> 0); // A poisoned callee's refusal names the original trap (polyengine#145). - if (!calleeInst.mayEnterFrom(callerInst)) { - trap(withPoisonCause( - calleeInst, - "cannot enter component instance", - )); - } + // Check-only: no `enterFrom` here, so no bracket to break. + const refusal = entryRefusal( + calleeInst, + callerInst, + "cannot enter component instance", + ); + if (refusal !== null) trap(refusal); } // `async_` records whether the callee is *async-lifted*. wasmtime // stores it on the guest task it creates here diff --git a/runtime/src/task/mod.ts b/runtime/src/task/mod.ts index 07d1d0e..24a6719 100644 --- a/runtime/src/task/mod.ts +++ b/runtime/src/task/mod.ts @@ -20,6 +20,7 @@ import { type Cancelled, CANCELLED_TRUE, chooseCandidate, + isInstancePoisoned, Store, dbgId, NeedsJspi, @@ -509,7 +510,14 @@ export class Task { } } } - if (candidates.length > 0 && this.inst.mayEnterFrom(caller)) { + // 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)) { this.state = "cancel-delivered"; this.inst.enterFrom(caller); try { diff --git a/runtime/src/task/scheduler.ts b/runtime/src/task/scheduler.ts index 09d012e..86573de 100644 --- a/runtime/src/task/scheduler.ts +++ b/runtime/src/task/scheduler.ts @@ -209,6 +209,53 @@ export function withPoisonCause(inst: object, base: string): string { return `${base} — instance poisoned by: ${cause}`; } +/** + * The entry-refusal decision, in one place: may `caller` enter `callee` right + * 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`. + * + * 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. + * + * 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. + */ +export function entryRefusal( + callee: { mayEnterFrom(caller: unknown): boolean }, + 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; +} + function describeCause(cause: unknown): string { try { // String(err) renders "Name: message" — for a `Trap`, exactly the @@ -1170,8 +1217,12 @@ export class Store { // 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. const candidates = this.readyCandidates().filter((t) => - t.task.inst.mayEnterFrom(null) + !isInstancePoisoned(t.task.inst) && t.task.inst.mayEnterFrom(null) ); if (candidates.length === 0) return false; const thread = chooseCandidate(candidates); diff --git a/runtime/tests/task_test.ts b/runtime/tests/task_test.ts index 483452a..ebcd622 100644 --- a/runtime/tests/task_test.ts +++ b/runtime/tests/task_test.ts @@ -14,6 +14,7 @@ import { chooseCandidate, ComponentInstanceState, driveSyncLift, + entryRefusal, EventCode, packSubtaskResult, schedulerPolicy, @@ -917,6 +918,117 @@ Deno.test("request_cancellation: a capability signal releases the gate", () => { assertEq(isInstancePoisoned(b), false, "and 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; +} + +Deno.test("re-key: entryRefusal refuses a marked instance with mayEnter forced true", () => { + 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"); + assertEq(r.includes("cannot enter component instance"), true); + assertEq(r.includes("instance poisoned by"), true, "the cause is named"); + 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", () => { + const store = new Store(); + const inst = new ComponentInstanceState(0, store); + assertEq(entryRefusal(inst, null, "base"), null); +}); + +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. + 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", () => { + const store = new Store(); + const inst = new ComponentInstanceState(0, store); + let flag = false; + const order: string[] = []; + const task = mkTask(inst, SYNC_FT, SYNC_OPTS); + const thread = spawn(task, function* (thread) { + yield* task.enterImplicitThread(thread); + task.start(); + yield* thread.waitUntil(() => flag, false); + order.push("ran"); + task.return_([]); + task.exitImplicitThread(thread); + }); + thread.resume(); + flag = true; + 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); +}); + +Deno.test("re-key: requestCancellation leaves a marked callee's request pending", () => { + const store = new Store(); + const callerInst = new ComponentInstanceState(0, store); + const b = new ComponentInstanceState(1, store); + let sawCancel = false; + const task = mkTask(b, ASYNC_FT, STACKFUL_OPTS); + const thread = spawn(task, function* (thread) { + yield* task.enterImplicitThread(thread); + task.start(); + const cancelled = yield* thread.waitUntil(() => false, true); + if (cancelled) { + sawCancel = true; + task.cancel(); + } + task.exitImplicitThread(thread); + }); + thread.resume(); + 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"); + assertEq(task.state, "pending-cancel"); +}); + Deno.test("cancellation: task.cancel without a delivered request traps", () => { const inst = new ComponentInstanceState(0); const task = mkTask(inst, ASYNC_FT, STACKFUL_OPTS);