Skip to content

Commit 69b6cd9

Browse files
authored
Merge pull request #164 from lann/fix/poison-marker-and-cancel-bracket
task core: record tick's poison marker; keep the bracket broken on cancellation-delivery traps
2 parents 7eb6c46 + f7abf96 commit 69b6cd9

3 files changed

Lines changed: 151 additions & 3 deletions

File tree

runtime/src/task/mod.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import {
2222
chooseCandidate,
2323
Store,
2424
dbgId,
25+
NeedsJspi,
26+
notifyInstancePoisoned,
27+
PendingCapability,
2528
} from "./scheduler.ts";
2629
import { Thread } from "./thread.ts";
2730
import { Waitable, WaitableSet } from "./waitable.ts";
@@ -511,9 +514,35 @@ export class Task {
511514
this.inst.enterFrom(caller);
512515
try {
513516
chooseCandidate(candidates).resume(CANCELLED_TRUE);
514-
} finally {
515-
this.inst.leaveTo(caller);
517+
} catch (e) {
518+
// Deliberately NOT a `finally`, mirroring `Store.tick`'s
519+
// bracket-break discipline (scheduler.ts): the reference wraps the
520+
// delivery `resume(Cancelled.TRUE)` in no handler at all
521+
// (definitions.py `Task.request_cancellation`, lines 519-532; the
522+
// delivery is line 531), so a Trap escaping it never reaches
523+
// `leave_to` on line 532 — the entered set stays locked, i.e. the
524+
// Component Model's instance poisoning. A `finally` here would
525+
// un-poison a half-unwound callee.
526+
//
527+
// Capability signals are the exception, exactly as in `tick`: they
528+
// mark this RUNTIME incomplete, not the component faulted, and in
529+
// the reference the blocking operation completes and `leave_to` IS
530+
// reached.
531+
if (e instanceof NeedsJspi || e instanceof PendingCapability) {
532+
this.inst.leaveTo(caller);
533+
} else {
534+
// The synthetic root is released so the poisoning stays
535+
// per-instance (plan v3 amendment 4); for a guest caller the
536+
// entering set is the leaf alone and the release is a no-op.
537+
this.inst.releaseSyntheticRootOnPoison();
538+
notifyInstancePoisoned(
539+
this.inst as unknown as { handles: Iterable<unknown> },
540+
e,
541+
);
542+
}
543+
throw e;
516544
}
545+
this.inst.leaveTo(caller);
517546
} else {
518547
this.state = "pending-cancel";
519548
}

runtime/src/task/scheduler.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1062,8 +1062,18 @@ export class Store {
10621062
// in this entry's entering set but must not turn per-instance
10631063
// poisoning into store-wide poisoning. See
10641064
// `ComponentInstanceState.releaseSyntheticRootOnPoison`.
1065+
//
1066+
// Routed through `notifyInstancePoisoned` (not the raw hook) so the
1067+
// poison MARKER is recorded too (deltic#145): `Thread.resumeWith`'s
1068+
// quiet-retire of late settled tails and `dispatchableTail`'s
1069+
// dispatch-or-defer decision (#156) both read it, and without the
1070+
// marker a settled tail of this instance would hit the backstop
1071+
// assert or defer forever.
10651072
inst.releaseSyntheticRootOnPoison?.();
1066-
onInstancePoisoned?.(inst, e);
1073+
notifyInstancePoisoned(
1074+
inst as unknown as { handles: Iterable<unknown> },
1075+
e,
1076+
);
10671077
}
10681078
throw e;
10691079
}

runtime/tests/task_test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import {
1818
packSubtaskResult,
1919
schedulerPolicy,
2020
schedulerSeedForTesting,
21+
isInstancePoisoned,
2122
notifyInstancePoisoned,
23+
PendingCapability,
2224
Store,
2325
Subtask,
2426
SubtaskState,
@@ -27,6 +29,7 @@ import {
2729
Thread,
2830
unpackSubtaskResult,
2931
WaitableSet,
32+
withPoisonCause,
3033
} from "../src/task/mod.ts";
3134
import type { FuncType } from "../src/cabi/types.ts";
3235

@@ -808,6 +811,112 @@ Deno.test("cancellation: with no cancellable thread it becomes pending", () => {
808811
assertEq(task.state, "resolved");
809812
});
810813

814+
Deno.test("tick: a trap under tick records the poison marker", async () => {
815+
// A trap escaping `thread.resume()` under `Store.tick` breaks the
816+
// enter/leave bracket (definitions.py `Store.tick`, line 597) — and must
817+
// also record the poison MARKER, which `Thread.resumeWith`'s quiet-retire
818+
// and `dispatchableTail` read (deltic#145, #156).
819+
const store = new Store();
820+
const b = new ComponentInstanceState(0, store);
821+
822+
// A second thread of B, parked on a host promise BEFORE the trap.
823+
let settle!: () => void;
824+
const p = new Promise<void>((r) => {
825+
settle = r;
826+
});
827+
const order: string[] = [];
828+
const parkedTask = mkTask(b, SYNC_FT, SYNC_OPTS);
829+
const parkedThread = spawn(parkedTask, function* (thread) {
830+
yield* parkedTask.enterImplicitThread(thread);
831+
parkedTask.start();
832+
yield { readyFunc: null, cancellable: false, awaitValue: p };
833+
order.push("parked tail ran");
834+
parkedTask.return_([]);
835+
parkedTask.exitImplicitThread(thread);
836+
});
837+
parkedThread.resume();
838+
assertEq(store.awaiting.has(parkedThread), true, "the sibling is parked");
839+
840+
// A waiting+ready thread of B whose resumption traps.
841+
let flag = false;
842+
const trapTask = mkTask(b, ASYNC_FT, STACKFUL_OPTS);
843+
const trapThread = spawn(trapTask, function* (thread) {
844+
yield* trapTask.enterImplicitThread(thread);
845+
trapTask.start();
846+
yield* thread.waitUntil(() => flag, false);
847+
throw new Trap("boom under tick");
848+
});
849+
trapThread.resume();
850+
flag = true;
851+
assertEq(trapThread.ready(), true);
852+
853+
assertThrows(() => store.tick(), "boom under tick");
854+
855+
assertEq(isInstancePoisoned(b), true, "the poison marker is recorded");
856+
assertEq(b.mayEnterFrom(null), false, "and the bracket stays broken");
857+
assert(
858+
withPoisonCause(b, "x").includes("boom under tick"),
859+
"the cause is available for entry-refusal diagnostics",
860+
);
861+
862+
// #156 interaction: the settled tail of the poisoned instance drains
863+
// quietly instead of hitting `resumeWith`'s backstop assert (or deferring
864+
// forever, which is what `dispatchableTail` would do without the marker).
865+
await queueSettledTail(settle);
866+
assertEq(store.settled.length, 1, "the tail is queued");
867+
assertEq(store.serviceSettled(), true, "poisoned tails dispatch");
868+
assertEq(store.settled.length, 0, "the queue drains");
869+
assertEq(order.length, 0, "retired quietly: the body never ran");
870+
});
871+
872+
Deno.test("request_cancellation: a trap during delivery poisons the callee", () => {
873+
// definitions.py `Task.request_cancellation` (lines 519-532) wraps the
874+
// delivery `resume(Cancelled.TRUE)` in no handler: a Trap skips `leave_to`.
875+
const store = new Store();
876+
const callerInst = new ComponentInstanceState(0, store);
877+
const b = new ComponentInstanceState(1, store);
878+
const task = mkTask(b, ASYNC_FT, STACKFUL_OPTS);
879+
const thread = spawn(task, function* (thread) {
880+
yield* task.enterImplicitThread(thread);
881+
task.start();
882+
const cancelled = yield* thread.waitUntil(() => false, true);
883+
if (cancelled) throw new Trap("boom during cancel delivery");
884+
});
885+
thread.resume();
886+
assertEq(task.state, "started");
887+
888+
assertThrows(
889+
() => task.requestCancellation(callerInst),
890+
"boom during cancel delivery",
891+
);
892+
assertEq(b.mayEnterFrom(callerInst), false, "the callee stays locked");
893+
assertEq(isInstancePoisoned(b), true);
894+
assertEq(task.state, "cancel-delivered", "parity: the state is set first");
895+
});
896+
897+
Deno.test("request_cancellation: a capability signal releases the gate", () => {
898+
// Capability signals mark the RUNTIME incomplete, not the component
899+
// faulted: the bracket is released, exactly as in `Store.tick`.
900+
const store = new Store();
901+
const callerInst = new ComponentInstanceState(0, store);
902+
const b = new ComponentInstanceState(1, store);
903+
const task = mkTask(b, ASYNC_FT, STACKFUL_OPTS);
904+
const thread = spawn(task, function* (thread) {
905+
yield* task.enterImplicitThread(thread);
906+
task.start();
907+
const cancelled = yield* thread.waitUntil(() => false, true);
908+
if (cancelled) throw new PendingCapability("x");
909+
});
910+
thread.resume();
911+
912+
assertThrows(
913+
() => task.requestCancellation(callerInst),
914+
"pending-capability: x",
915+
);
916+
assertEq(b.mayEnterFrom(callerInst), true, "the gate is released");
917+
assertEq(isInstancePoisoned(b), false, "and nothing is poisoned");
918+
});
919+
811920
Deno.test("cancellation: task.cancel without a delivered request traps", () => {
812921
const inst = new ComponentInstanceState(0);
813922
const task = mkTask(inst, ASYNC_FT, STACKFUL_OPTS);

0 commit comments

Comments
 (0)