@@ -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" ;
3134import 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+
811920Deno . 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