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
8 changes: 6 additions & 2 deletions runtime/src/cabi/handles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
// which reconstructs the reference's store.lift/store.lower bracket
// (may_enter gating + trap poisoning) around the destructor call (#85).

import { assert_, Trap, trapIf } from "./trap.ts";
import { assert_, Trap, trap, trapIf } from "./trap.ts";
import {
NeedsJspi,
notifyInstancePoisoned,
PendingCapability,
withPoisonCause,
} from "../task/scheduler.ts";
import type {
ComponentInstanceLike,
Expand Down Expand Up @@ -260,7 +261,10 @@ export function callDtorGated(
// reference's `caller = None` (Store.invoke).
const callerInst = asGate(caller) === null ? null : caller;

trapIf(!impl.mayEnterFrom(callerInst), "cannot enter component instance");
// A poisoned target's refusal names the original trap (deltic#145).
if (!impl.mayEnterFrom(callerInst)) {
trap(withPoisonCause(impl, "cannot enter component instance"));
}
impl.enterFrom(callerInst);

const poison = (e: unknown): void => {
Expand Down
13 changes: 9 additions & 4 deletions runtime/src/exec/boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
Task,
type TaskOptions,
Thread,
withPoisonCause,
} from "../task/mod.ts";
import { currentTask } from "../task/scheduler.ts";
import { PlanError } from "../plan/loader.ts";
Expand Down Expand Up @@ -217,7 +218,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 221 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 221 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 @@ -1156,10 +1157,14 @@

// Reference `Store.lift` (line 578): the host is the caller, so the
// entering set is the callee's `self_and_ancestors()`.
trapIf(
!inst.mayEnterFrom(null),
`cannot enter component instance ${inst.index} (reentrance forbidden)`,
);
// On refusal, distinguish the corpse from the crowd: a poisoned
// instance's refusal names the original trap (deltic#145 ask 1).
if (!inst.mayEnterFrom(null)) {
trap(withPoisonCause(
inst,
`cannot enter component instance ${inst.index} (reentrance forbidden)`,
));
}
// 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.
Expand Down Expand Up @@ -1628,7 +1633,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 1636 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 1636 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 @@ -1674,7 +1679,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 1682 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 1682 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
25 changes: 16 additions & 9 deletions runtime/src/intrinsics/fact_calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
// is the ordinary "is the callee instance currently executing" one, which
// a flat tree answers correctly.

import { assert_, trapIf } from "../cabi/trap.ts";
import { assert_, trap } from "../cabi/trap.ts";
import { MAX_FLAT_RESULTS } from "../cabi/mod.ts";
import type { CoreValue, FuncType, ValType } from "../cabi/types.ts";
import {
Expand All @@ -85,6 +85,7 @@ import {
Task,
type TaskOptions,
Thread,
withPoisonCause,
} from "../task/mod.ts";
import { blockCurrentActivation, enterWasm } from "../jspi/mod.ts";
import {
Expand Down Expand Up @@ -650,10 +651,13 @@ export function createSyncStartCall(

// Reference `Store.lift`: the reentrance gate, with the *caller* as the
// entering context (definitions.py `entering_set(caller)`).
trapIf(
!prepared.calleeInst.mayEnterFrom(prepared.callerInst),
"cannot enter component instance",
);
// A poisoned callee's refusal names the original trap (deltic#145).
if (!prepared.calleeInst.mayEnterFrom(prepared.callerInst)) {
trap(withPoisonCause(
prepared.calleeInst,
"cannot enter component instance",
));
}
prepared.calleeInst.enterFrom(prepared.callerInst);
let ok = false;
try {
Expand Down Expand Up @@ -891,10 +895,13 @@ export function createAsyncStartCall(
subtask.onCancel = (callerInst) => task.requestCancellation(callerInst);
subtask.calleeTask = task;

trapIf(
!prepared.calleeInst.mayEnterFrom(prepared.callerInst),
"cannot enter component instance",
);
// A poisoned callee's refusal names the original trap (deltic#145).
if (!prepared.calleeInst.mayEnterFrom(prepared.callerInst)) {
trap(withPoisonCause(
prepared.calleeInst,
"cannot enter component instance",
));
}
prepared.calleeInst.enterFrom(prepared.callerInst);
let ok = false;
let thread: Thread;
Expand Down
13 changes: 8 additions & 5 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 } from "../task/mod.ts";
import { maybeCurrentThread, maybeCurrentTask, PendingCapability, withPoisonCause } 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 @@ -526,10 +526,13 @@ function createTrampolineBody(
) {
const callerInst = ctx.componentInstance(callerInstance >>> 0);
const calleeInst = ctx.componentInstance(calleeInstance >>> 0);
trapIf(
!calleeInst.mayEnterFrom(callerInst),
"cannot enter component instance",
);
// A poisoned callee's refusal names the original trap (deltic#145).
if (!calleeInst.mayEnterFrom(callerInst)) {
trap(withPoisonCause(
calleeInst,
"cannot enter component instance",
));
}
}
// `async_` records whether the callee is *async-lifted*. wasmtime
// stores it on the guest task it creates here
Expand Down
55 changes: 49 additions & 6 deletions runtime/src/task/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,20 +145,63 @@ export function notifyInstancePoisoned(
inst: { handles: Iterable<unknown> },
cause: unknown,
): void {
poisonedInstances.add(inst);
// First cause wins: a poisoned instance can collect follow-on failures
// (late settles retired against it, repeated bracket breaks), and the
// original trap is the one worth reporting on later entry refusals
// (deltic#145 ask 1).
if (!poisonedInstances.has(inst)) poisonedInstances.set(inst, cause);
onInstancePoisoned?.(inst, cause);
}

/** Poisoned instances, for late-settle retirement (`Thread.resumeWith`):
* a WeakSet mirror of streams.ts's `retiredInstances`, kept here because
* thread.ts cannot import streams.ts (the same evaluation-order
* constraint that made `setOnInstancePoisoned` an injection seam). */
const poisonedInstances = new WeakSet<object>();
/** Poisoned instances → poisoning cause, for late-settle retirement
* (`Thread.resumeWith`) and entry-refusal diagnostics (`withPoisonCause`,
* deltic#145). A WeakMap mirror of streams.ts's `retiredInstances`, kept
* here because thread.ts cannot import streams.ts (the same
* evaluation-order constraint that made `setOnInstancePoisoned` an
* injection seam). */
const poisonedInstances = new WeakMap<object, unknown>();

export function isInstancePoisoned(inst: object): boolean {
return poisonedInstances.has(inst);
}

/**
* The recorded cause of an instance's poisoning: the original trap that
* broke the enter/leave bracket (deltic#145). `undefined` when the instance
* is not poisoned — and, degenerately, when the poisoning cause itself was
* a thrown `undefined`; use `isInstancePoisoned` for the predicate.
*/
export function instancePoisonCause(inst: object): unknown {
return poisonedInstances.get(inst);
}

/**
* Append the recorded poison cause to an entry-refusal trap message
* (deltic#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
* conformance-safe because the official suite matches trap messages by
* substring (harness/src/runner.ts).
*/
export function withPoisonCause(inst: object, base: string): string {
if (!poisonedInstances.has(inst)) return base;
const cause = describeCause(poisonedInstances.get(inst));
return `${base} — instance poisoned by: ${cause}`;
}

function describeCause(cause: unknown): string {
try {
// String(err) renders "Name: message" — for a `Trap`, exactly the
// original trap line the embedder needs to see.
return String(cause);
} catch {
return "(unprintable poison cause)";
}
}

// ---------------------------------------------------------------------------
// Deterministic choice
// ---------------------------------------------------------------------------
Expand Down
8 changes: 8 additions & 0 deletions runtime/tests/enter_sync_call_reentrance_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ Deno.test("enter-sync-call: sibling cycle A -> C -> A traps", () => {
true,
`expected the reentrance trap, got: ${msg || "<no trap>"}`,
);
// deltic#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}`,
);
});

Deno.test("enter-sync-call: an acyclic sibling chain A -> B -> C never traps", () => {
Expand Down
18 changes: 18 additions & 0 deletions runtime/tests/integration/e2e_hello_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,22 @@ Deno.test("task model: reentrance gate blocks concurrent entry", async () => {
assert(threw, "number lowered as string must fail");
assert(!inst.mayEnter, "a failed call must poison the instance");
assertTrap(() => greet("after-poison"), "cannot enter component instance");
// deltic#145 ask 1: the poisoned refusal names the original cause, so the
// embedder is not sent chasing transient caller-side call overlap. The
// cause here is the host-side lowering error above — non-Trap causes must
// surface too.
let refusal = "";
try {
greet("after-poison-again");
} catch (e) {
refusal = String((e as Error).message ?? e);
}
assert(
refusal.includes("(reentrance forbidden)"),
`refusal must keep the base wording, got: ${refusal}`,
);
assert(
refusal.includes("instance poisoned by:"),
`refusal must name the poison cause, got: ${refusal}`,
);
});
88 changes: 88 additions & 0 deletions runtime/tests/poison_cause_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// deltic#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
// enter_sync_call_reentrance_test.ts.
//
// No hook manipulation here: if task/streams.ts's retirement walk happens to
// be registered on the poisoning seam (another test file in this process
// imported it), `retireInstanceAsyncEnds` is a no-op on a stub instance with
// an empty handle table.

import { assertEq } from "./support/asserts.ts";
import {
instancePoisonCause,
isInstancePoisoned,
notifyInstancePoisoned,
withPoisonCause,
} from "../src/task/scheduler.ts";
import { Trap } from "../src/cabi/trap.ts";

function fakeInst(): { handles: Iterable<unknown> } {
return { handles: [] };
}

Deno.test("poison cause: recorded, queryable, first cause wins", () => {
const inst = fakeInst();
assertEq(isInstancePoisoned(inst), false, "fresh instance is not poisoned");
assertEq(instancePoisonCause(inst), undefined, "no cause before poisoning");

const original = new Trap(
"cannot leave component instance 1 (may_leave violation)",
);
notifyInstancePoisoned(inst, original);
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
// original cause — it is the one worth reporting.
notifyInstancePoisoned(inst, new Trap("second victim"));
assertEq(
instancePoisonCause(inst) === original,
true,
"first cause wins over follow-on failures",
);
});

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)" +
" — 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
assertEq(
withPoisonCause(inst, "cannot enter component instance"),
"cannot enter component instance",
);
});

Deno.test("poison cause: non-Error and unprintable causes degrade safely", () => {
const plain = fakeInst();
notifyInstancePoisoned(plain, "a thrown string");
assertEq(
withPoisonCause(plain, "base"),
"base — instance poisoned by: a thrown string",
);

const unprintable = fakeInst();
notifyInstancePoisoned(unprintable, {
toString(): string {
throw new Error("nope");
},
});
assertEq(
withPoisonCause(unprintable, "base"),
"base — instance poisoned by: (unprintable poison cause)",
);
});
Loading
Loading