33 */
44import { beforeEach , describe , expect , it , vi } from 'vitest'
55
6- const { mockCheckAttributedUsageLimits, mockSubscribe, mockUnsubscribe } = vi . hoisted ( ( ) => ( {
6+ const {
7+ mockCheckAttributedUsageLimits,
8+ mockSubscribe,
9+ mockUnsubscribe,
10+ mockIsExecutionCancelled,
11+ mockIsRedisCancellationEnabled,
12+ } = vi . hoisted ( ( ) => ( {
713 mockCheckAttributedUsageLimits : vi . fn ( ) ,
814 mockSubscribe : vi . fn ( ) ,
915 mockUnsubscribe : vi . fn ( ) ,
16+ mockIsExecutionCancelled : vi . fn ( ) ,
17+ mockIsRedisCancellationEnabled : vi . fn ( ) ,
1018} ) )
1119
1220vi . mock ( '@/lib/billing/core/billing-attribution' , ( ) => ( {
@@ -15,6 +23,8 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({
1523
1624vi . mock ( '@/lib/execution/cancellation' , ( ) => ( {
1725 getCancellationChannel : ( ) => ( { subscribe : mockSubscribe } ) ,
26+ isExecutionCancelled : mockIsExecutionCancelled ,
27+ isRedisCancellationEnabled : mockIsRedisCancellationEnabled ,
1828} ) )
1929
2030import {
@@ -31,6 +41,8 @@ describe('admitCustomBlockChildExecution', () => {
3141 beforeEach ( ( ) => {
3242 vi . clearAllMocks ( )
3343 mockSubscribe . mockReturnValue ( mockUnsubscribe )
44+ mockIsRedisCancellationEnabled . mockReturnValue ( true )
45+ mockIsExecutionCancelled . mockResolvedValue ( false )
3446 } )
3547
3648 it ( 'passes when the source payer has headroom' , async ( ) => {
@@ -88,11 +100,13 @@ describe('createChildCancellationSignal', () => {
88100 beforeEach ( ( ) => {
89101 vi . clearAllMocks ( )
90102 mockSubscribe . mockReturnValue ( mockUnsubscribe )
103+ mockIsRedisCancellationEnabled . mockReturnValue ( true )
104+ mockIsExecutionCancelled . mockResolvedValue ( false )
91105 } )
92106
93- it ( 'aborts when the parent signal aborts' , ( ) => {
107+ it ( 'aborts when the parent signal aborts' , async ( ) => {
94108 const parent = new AbortController ( )
95- const { signal } = createChildCancellationSignal ( {
109+ const { signal } = await createChildCancellationSignal ( {
96110 parentSignal : parent . signal ,
97111 parentExecutionId : 'parent-1' ,
98112 } )
@@ -102,17 +116,17 @@ describe('createChildCancellationSignal', () => {
102116 expect ( signal . aborted ) . toBe ( true )
103117 } )
104118
105- it ( 'starts aborted when the parent already aborted' , ( ) => {
119+ it ( 'starts aborted when the parent already aborted' , async ( ) => {
106120 const parent = new AbortController ( )
107121 parent . abort ( )
108122
109- const { signal } = createChildCancellationSignal ( { parentSignal : parent . signal } )
123+ const { signal } = await createChildCancellationSignal ( { parentSignal : parent . signal } )
110124
111125 expect ( signal . aborted ) . toBe ( true )
112126 } )
113127
114- it ( 'aborts on the parent cancellation event, and ignores other runs' , ( ) => {
115- const { signal } = createChildCancellationSignal ( { parentExecutionId : 'parent-1' } )
128+ it ( 'aborts on the parent cancellation event, and ignores other runs' , async ( ) => {
129+ const { signal } = await createChildCancellationSignal ( { parentExecutionId : 'parent-1' } )
116130 const handler = mockSubscribe . mock . calls [ 0 ] [ 0 ]
117131
118132 handler ( { executionId : 'someone-else' } )
@@ -122,9 +136,9 @@ describe('createChildCancellationSignal', () => {
122136 expect ( signal . aborted ) . toBe ( true )
123137 } )
124138
125- it ( 'unsubscribes on dispose so a looped block leaks nothing' , ( ) => {
139+ it ( 'unsubscribes on dispose so a looped block leaks nothing' , async ( ) => {
126140 const parent = new AbortController ( )
127- const { dispose } = createChildCancellationSignal ( {
141+ const { dispose } = await createChildCancellationSignal ( {
128142 parentSignal : parent . signal ,
129143 parentExecutionId : 'parent-1' ,
130144 } )
@@ -144,3 +158,55 @@ describe('CustomBlockAdmissionError', () => {
144158 expect ( error . message ) . toBe ( 'Organization usage limit exceeded' )
145159 } )
146160} )
161+
162+ describe ( 'createChildCancellationSignal durable backstop' , ( ) => {
163+ beforeEach ( ( ) => {
164+ vi . clearAllMocks ( )
165+ mockSubscribe . mockReturnValue ( mockUnsubscribe )
166+ mockIsRedisCancellationEnabled . mockReturnValue ( true )
167+ mockIsExecutionCancelled . mockResolvedValue ( false )
168+ } )
169+
170+ it ( 'aborts on a cancel published before the bridge subscribed' , async ( ) => {
171+ // The pub/sub event is long gone; only the durable key remains.
172+ mockIsExecutionCancelled . mockResolvedValue ( true )
173+
174+ const { signal } = await createChildCancellationSignal ( { parentExecutionId : 'parent-1' } )
175+
176+ expect ( mockIsExecutionCancelled ) . toHaveBeenCalledWith ( 'parent-1' )
177+ expect ( signal . aborted ) . toBe ( true )
178+ } )
179+
180+ it ( 'subscribes before reading the durable key so no window is left open' , async ( ) => {
181+ const order : string [ ] = [ ]
182+ mockSubscribe . mockImplementation ( ( ) => {
183+ order . push ( 'subscribe' )
184+ return mockUnsubscribe
185+ } )
186+ mockIsExecutionCancelled . mockImplementation ( async ( ) => {
187+ order . push ( 'durable-read' )
188+ return false
189+ } )
190+
191+ await createChildCancellationSignal ( { parentExecutionId : 'parent-1' } )
192+
193+ expect ( order ) . toEqual ( [ 'subscribe' , 'durable-read' ] )
194+ } )
195+
196+ it ( 'fails open when the durable read throws' , async ( ) => {
197+ mockIsExecutionCancelled . mockRejectedValue ( new Error ( 'redis down' ) )
198+
199+ const { signal } = await createChildCancellationSignal ( { parentExecutionId : 'parent-1' } )
200+
201+ expect ( signal . aborted ) . toBe ( false )
202+ } )
203+
204+ it ( 'skips the durable read when redis cancellation is unavailable' , async ( ) => {
205+ mockIsRedisCancellationEnabled . mockReturnValue ( false )
206+
207+ const { signal } = await createChildCancellationSignal ( { parentExecutionId : 'parent-1' } )
208+
209+ expect ( mockIsExecutionCancelled ) . not . toHaveBeenCalled ( )
210+ expect ( signal . aborted ) . toBe ( false )
211+ } )
212+ } )
0 commit comments