@@ -599,6 +599,79 @@ describe("each gate decision appends exactly one transcript row", () => {
599599 }
600600 } )
601601 } )
602+
603+ // The queue (settle-once guard) and the transcript recorder (record-once
604+ // per decision) are two independent mechanisms layered on the same set of
605+ // terminal paths. Racing a timeout against an abort on the same request
606+ // exercises both at once: clearTimers must retire the loser before it can
607+ // run autoDeny a second time, so ev.resolve fires exactly once and exactly
608+ // one row lands, no matter which trigger wins.
609+ test ( "a timeout and an abort racing the same request settle once and record once" , async ( ) => {
610+ await withTestRenderer ( async ( h ) => {
611+ const shell = createAppShell ( h . renderer , {
612+ terminal : { columns : 80 , rows : 24 } ,
613+ run : "idle" ,
614+ } )
615+ const emitter = new EventEmitter ( )
616+ const controller = new AbortController ( )
617+ let resolveCount = 0
618+ try {
619+ wireGates ( emitter , shell )
620+ const before = shell . streamLog . length
621+ emitter . emit ( "permission.gate" , {
622+ request : baseRequest ( ) ,
623+ resolve : ( ) => {
624+ resolveCount += 1
625+ } ,
626+ timeoutMs : 5 ,
627+ signal : controller . signal ,
628+ } )
629+
630+ await new Promise ( ( r ) => setTimeout ( r , 20 ) )
631+ // The timeout already fired and cleared the abort listener — this
632+ // must be a no-op, not a second settle.
633+ controller . abort ( )
634+
635+ expect ( resolveCount ) . toBe ( 1 )
636+ expect ( shell . streamLog . length - before ) . toBe ( 1 )
637+ } finally {
638+ shell . dispose ( )
639+ }
640+ } )
641+ } )
642+
643+ test ( "a queued gate's timeout settles once and records once without ever opening" , async ( ) => {
644+ await withTestRenderer ( async ( h ) => {
645+ const shell = createAppShell ( h . renderer , {
646+ terminal : { columns : 80 , rows : 24 } ,
647+ run : "idle" ,
648+ } )
649+ const emitter = new EventEmitter ( )
650+ let resolveCount = 0
651+ try {
652+ wireGates ( emitter , shell )
653+ emitter . emit ( "permission.gate" , {
654+ request : baseRequest ( ) ,
655+ resolve : ( ) => { } ,
656+ } )
657+ const before = shell . streamLog . length
658+ emitter . emit ( "permission.gate" , {
659+ request : baseRequest ( { tool : "queued_tool" } ) ,
660+ resolve : ( ) => {
661+ resolveCount += 1
662+ } ,
663+ timeoutMs : 5 ,
664+ } )
665+
666+ await new Promise ( ( r ) => setTimeout ( r , 20 ) )
667+
668+ expect ( resolveCount ) . toBe ( 1 )
669+ expect ( shell . streamLog . length - before ) . toBe ( 1 )
670+ } finally {
671+ shell . dispose ( )
672+ }
673+ } )
674+ } )
602675} )
603676
604677describe ( "permission.gate auto-deny" , ( ) => {
0 commit comments