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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions runtime/src/cabi/handles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
NeedsJspi,
notifyInstancePoisoned,
PendingCapability,
withPoisonCause,
entryRefusal,
} from "../task/scheduler.ts";
import type {
ComponentInstanceLike,
Expand Down Expand Up @@ -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);

Expand Down
10 changes: 6 additions & 4 deletions runtime/src/exec/boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
Task,
type TaskOptions,
Thread,
withPoisonCause,
entryRefusal,
} from "../task/mod.ts";
import { currentTask } from "../task/scheduler.ts";
import { PlanError } from "../plan/loader.ts";
Expand Down Expand Up @@ -217,7 +217,7 @@
stringEncoding: opts.stringEncoding,
memory: opts.memory,
realloc: opts.realloc === null ? null : (o, os, a, n) => {
const realloc = require(opts.realloc, "realloc")!;

Check warning on line 220 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04-arm)

unable to analyze dynamic import

Check warning on line 220 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04)

unable to analyze dynamic import
const p = callCore(realloc, [o, os, a, n]);
trapIf(p.length !== 1 || typeof p[0] !== "number", "realloc result");
return (p[0] as number) >>> 0;
Expand Down Expand Up @@ -1376,11 +1376,13 @@
// 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
Expand Down Expand Up @@ -2019,7 +2021,7 @@
task.return_(results);
// Post-return runs after the results were read out of guest memory,
// with may_leave cleared (reference canon_lift).
const postReturn = require(opts.postReturn, `${name} post-return`);

Check warning on line 2024 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04-arm)

unable to analyze dynamic import

Check warning on line 2024 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04)

unable to analyze dynamic import
if (postReturn !== null) {
assert_(inst.mayLeave, "post-return with may_leave already false");
inst.mayLeave = false;
Expand Down Expand Up @@ -2065,7 +2067,7 @@
// *mixed* activation, which pin (c) punishes: the first Suspending import
// it reached would trap.
const callback = enterWasm(
require(opts.callback, `${name} callback`)!,

Check warning on line 2070 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04-arm)

unable to analyze dynamic import

Check warning on line 2070 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04)

unable to analyze dynamic import
input.mode,
);
const [packed] = normalizeCoreValues(
Expand Down
18 changes: 11 additions & 7 deletions runtime/src/intrinsics/fact_calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ import {
Task,
type TaskOptions,
Thread,
withPoisonCause,
entryRefusal,
} from "../task/mod.ts";
import { blockCurrentActivation, enterWasm } from "../jspi/mod.ts";
import {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 8 additions & 7 deletions runtime/src/intrinsics/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion runtime/src/task/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type Cancelled,
CANCELLED_TRUE,
chooseCandidate,
isInstancePoisoned,
Store,
dbgId,
NeedsJspi,
Expand Down Expand Up @@ -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 {
Expand Down
53 changes: 52 additions & 1 deletion runtime/src/task/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
112 changes: 112 additions & 0 deletions runtime/tests/task_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
chooseCandidate,
ComponentInstanceState,
driveSyncLift,
entryRefusal,
EventCode,
packSubtaskResult,
schedulerPolicy,
Expand Down Expand Up @@ -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);
Expand Down
Loading