Skip to content

Commit 06256bd

Browse files
committed
Test that a raced timeout and abort settle once and record once
Reconciliation (queue.settle) and transcript recording (recordDecision) are two independent single-fire guards layered on the same terminal paths. Nothing exercised them together: a timeout and an abort racing the same request, or a timeout firing on a request that never opened an overlay.
1 parent b26dce6 commit 06256bd

1 file changed

Lines changed: 73 additions & 0 deletions

File tree

src/tui-opentui/gate-wire.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

604677
describe("permission.gate auto-deny", () => {

0 commit comments

Comments
 (0)