11/**
22 * @vitest -environment node
33 */
4+ import { EventEmitter } from 'node:events'
45import { beforeEach , describe , expect , it , vi } from 'vitest'
56
6- const { listeners, mockRedisUrl, mockSubscribe, mockUnsubscribe } = vi . hoisted ( ( ) => ( {
7- listeners : new Map < string , ( ...args : unknown [ ] ) => void > ( ) ,
7+ const { connection, mockRedisUrl, mockSubscribe, mockUnsubscribe } = vi . hoisted ( ( ) => ( {
8+ connection : {
9+ status : 'ready' ,
10+ client : undefined as EventEmitter | undefined ,
11+ } ,
812 mockRedisUrl : { value : 'redis://localhost:6379' as string | undefined } ,
913 mockSubscribe : vi . fn ( ) ,
1014 mockUnsubscribe : vi . fn ( ) ,
1115} ) )
1216
1317vi . mock ( 'ioredis' , ( ) => ( {
14- default : class {
15- on ( event : string , handler : ( ...args : unknown [ ] ) => void ) {
16- listeners . set ( event , handler )
17- return this
18+ default : class extends EventEmitter {
19+ constructor ( ) {
20+ super ( )
21+ connection . client = this
22+ }
23+
24+ get status ( ) {
25+ return connection . status
1826 }
1927
2028 subscribe = mockSubscribe
@@ -35,14 +43,226 @@ import {
3543describe ( 'ExecutionSignalHub' , ( ) => {
3644 beforeEach ( ( ) => {
3745 vi . clearAllMocks ( )
38- listeners . clear ( )
46+ connection . status = 'ready'
47+ connection . client = undefined
3948 mockSubscribe . mockResolvedValue ( 1 )
4049 mockUnsubscribe . mockResolvedValue ( 0 )
4150 mockRedisUrl . value = 'redis://localhost:6379'
4251 const signalGlobal = globalThis as typeof globalThis & { _executionSignalHub ?: unknown }
4352 signalGlobal . _executionSignalHub = undefined
4453 } )
4554
55+ it . each ( [ 'connecting' , 'connect' , 'reconnecting' ] ) (
56+ 'waits for Redis readiness while %s before subscribing concurrent execution channels' ,
57+ async ( status ) => {
58+ connection . status = status
59+ const hub = getExecutionSignalHub ( )
60+ const subscriptions = Array . from ( { length : 12 } , ( _ , index ) =>
61+ hub . subscribe ( `execution-${ index } ` , vi . fn ( ) )
62+ )
63+
64+ await Promise . resolve ( )
65+ expect ( mockSubscribe ) . not . toHaveBeenCalled ( )
66+ expect ( connection . client ?. listenerCount ( 'ready' ) ) . toBeLessThanOrEqual ( 2 )
67+
68+ connection . status = 'ready'
69+ connection . client ?. emit ( 'ready' )
70+ await Promise . all ( subscriptions )
71+
72+ expect ( mockSubscribe ) . toHaveBeenCalledTimes ( 12 )
73+ expect ( connection . client ?. listenerCount ( 'ready' ) ) . toBe ( 1 )
74+ expect ( connection . client ?. listenerCount ( 'error' ) ) . toBe ( 1 )
75+ expect ( connection . client ?. listenerCount ( 'end' ) ) . toBe ( 0 )
76+ }
77+ )
78+
79+ it ( 'rechecks readiness when the connection closes before waiting subscriptions resume' , async ( ) => {
80+ connection . status = 'connect'
81+ const hub = getExecutionSignalHub ( )
82+ const subscription = hub . subscribe ( 'execution-1' , vi . fn ( ) )
83+
84+ connection . status = 'ready'
85+ connection . client ?. emit ( 'ready' )
86+ connection . status = 'connect'
87+ connection . client ?. emit ( 'close' )
88+ await vi . waitFor ( ( ) => expect ( connection . client ?. listenerCount ( 'ready' ) ) . toBe ( 2 ) )
89+ expect ( mockSubscribe ) . not . toHaveBeenCalled ( )
90+
91+ connection . status = 'ready'
92+ connection . client ?. emit ( 'ready' )
93+ await subscription
94+ expect ( mockSubscribe ) . toHaveBeenCalled ( )
95+ } )
96+
97+ it ( 'rejects immediately when the subscriber has already ended' , async ( ) => {
98+ connection . status = 'end'
99+
100+ await expect ( getExecutionSignalHub ( ) . subscribe ( 'execution-1' , vi . fn ( ) ) ) . rejects . toThrow (
101+ 'Redis subscriber connection ended'
102+ )
103+ expect ( mockSubscribe ) . not . toHaveBeenCalled ( )
104+ expect ( connection . client ?. listenerCount ( 'ready' ) ) . toBe ( 1 )
105+ } )
106+
107+ it ( 'does not subscribe new channels while Redis is reconnecting' , async ( ) => {
108+ const hub = getExecutionSignalHub ( )
109+ connection . client ?. emit ( 'ready' )
110+ const handler = vi . fn ( )
111+ await hub . subscribe ( 'execution-existing' , handler )
112+ mockSubscribe . mockClear ( )
113+ connection . status = 'connect'
114+ connection . client ?. emit ( 'close' )
115+ const subscription = hub . subscribe ( 'execution-new' , vi . fn ( ) )
116+
117+ await Promise . resolve ( )
118+ expect ( mockSubscribe ) . not . toHaveBeenCalled ( )
119+
120+ connection . status = 'ready'
121+ connection . client ?. emit ( 'ready' )
122+ await subscription
123+ await vi . waitFor ( ( ) => expect ( handler ) . toHaveBeenCalledWith ( 'reconnected' ) )
124+ expect ( mockSubscribe ) . toHaveBeenCalledWith ( 'execution:signal:execution-new' , 'execution:cancel' )
125+ } )
126+
127+ it ( 'keeps waiting through recoverable connection errors' , async ( ) => {
128+ connection . status = 'connecting'
129+ const hub = getExecutionSignalHub ( )
130+ const handler = vi . fn ( )
131+ const subscription = hub . subscribe ( 'execution-1' , handler )
132+ const settled = vi . fn ( )
133+ void subscription . then ( settled , settled )
134+
135+ connection . client ?. emit ( 'error' , new Error ( 'ECONNREFUSED' ) )
136+ connection . status = 'reconnecting'
137+ connection . client ?. emit ( 'close' )
138+ await Promise . resolve ( )
139+ expect ( settled ) . not . toHaveBeenCalled ( )
140+ expect ( mockSubscribe ) . not . toHaveBeenCalled ( )
141+
142+ connection . status = 'ready'
143+ connection . client ?. emit ( 'ready' )
144+ await subscription
145+ expect ( mockSubscribe ) . toHaveBeenCalledOnce ( )
146+ connection . client ?. emit ( 'message' , 'execution:signal:execution-1' , 'cancelled' )
147+ expect ( handler ) . toHaveBeenCalledWith ( 'cancelled' )
148+ } )
149+
150+ it ( 'rejects readiness waiters when the subscriber stops reconnecting' , async ( ) => {
151+ connection . status = 'connect'
152+ const hub = getExecutionSignalHub ( )
153+ const subscription = hub . subscribe ( 'execution-1' , vi . fn ( ) )
154+ const rejected = expect ( subscription ) . rejects . toThrow ( 'Redis subscriber connection ended' )
155+
156+ connection . status = 'end'
157+ connection . client ?. emit ( 'end' )
158+ await rejected
159+ expect ( mockSubscribe ) . not . toHaveBeenCalled ( )
160+ expect ( connection . client ?. listenerCount ( 'ready' ) ) . toBe ( 1 )
161+ expect ( connection . client ?. listenerCount ( 'error' ) ) . toBe ( 1 )
162+ expect ( connection . client ?. listenerCount ( 'end' ) ) . toBe ( 0 )
163+ } )
164+
165+ it ( 'keeps a new channel independent of an existing channel reconnect failure' , async ( ) => {
166+ const hub = getExecutionSignalHub ( )
167+ connection . client ?. emit ( 'ready' )
168+ const existingHandler = vi . fn ( )
169+ await hub . subscribe ( 'execution-existing' , existingHandler )
170+ mockSubscribe . mockClear ( )
171+ connection . status = 'reconnecting'
172+ connection . client ?. emit ( 'close' )
173+ const newHandler = vi . fn ( )
174+ const subscription = hub . subscribe ( 'execution-new' , newHandler )
175+ let rejectReconnect ! : ( error : Error ) => void
176+ let acknowledgeNew ! : ( count : number ) => void
177+ mockSubscribe . mockReturnValueOnce (
178+ new Promise < number > ( ( _resolve , reject ) => {
179+ rejectReconnect = reject
180+ } )
181+ )
182+ mockSubscribe . mockReturnValueOnce (
183+ new Promise < number > ( ( resolve ) => {
184+ acknowledgeNew = resolve
185+ } )
186+ )
187+
188+ connection . status = 'ready'
189+ connection . client ?. emit ( 'ready' )
190+ await vi . waitFor ( ( ) => expect ( mockSubscribe ) . toHaveBeenCalledTimes ( 2 ) )
191+ expect ( mockSubscribe ) . toHaveBeenNthCalledWith (
192+ 1 ,
193+ 'execution:signal:execution-existing' ,
194+ 'execution:cancel'
195+ )
196+ expect ( mockSubscribe ) . toHaveBeenNthCalledWith (
197+ 2 ,
198+ 'execution:signal:execution-new' ,
199+ 'execution:cancel'
200+ )
201+
202+ rejectReconnect ( new Error ( 'Command timed out' ) )
203+ await vi . waitFor ( ( ) => expect ( existingHandler ) . toHaveBeenCalledWith ( 'unavailable' ) )
204+ expect ( newHandler ) . not . toHaveBeenCalled ( )
205+ acknowledgeNew ( 3 )
206+ await subscription
207+ connection . client ?. emit ( 'message' , 'execution:signal:execution-new' , 'cancelled' )
208+ expect ( newHandler ) . toHaveBeenCalledExactlyOnceWith ( 'cancelled' )
209+ } )
210+
211+ it ( 'preserves the pending acknowledgement when Redis reconnects before it arrives' , async ( ) => {
212+ const hub = getExecutionSignalHub ( )
213+ connection . client ?. emit ( 'ready' )
214+ let acknowledge ! : ( count : number ) => void
215+ mockSubscribe . mockReturnValueOnce (
216+ new Promise < number > ( ( resolve ) => {
217+ acknowledge = resolve
218+ } )
219+ )
220+ const handler = vi . fn ( )
221+ const subscription = hub . subscribe ( 'execution-new' , handler )
222+ connection . status = 'reconnecting'
223+ connection . client ?. emit ( 'close' )
224+ connection . status = 'ready'
225+ connection . client ?. emit ( 'ready' )
226+
227+ expect ( mockSubscribe ) . toHaveBeenCalledOnce ( )
228+ acknowledge ( 2 )
229+ await subscription
230+ expect ( handler ) . not . toHaveBeenCalled ( )
231+ } )
232+
233+ it ( 'bounds the readiness wait and removes failed handlers before a later ready event' , async ( ) => {
234+ vi . useFakeTimers ( )
235+ try {
236+ connection . status = 'connect'
237+ const hub = getExecutionSignalHub ( )
238+ const handler = vi . fn ( )
239+ const subscription = hub . subscribe ( 'execution-1' , handler )
240+ const rejected = expect ( subscription ) . rejects . toThrow (
241+ 'Timed out waiting for Redis subscriber readiness'
242+ )
243+
244+ const timeout = vi . advanceTimersByTimeAsync ( 4000 ) . then ( ( ) => {
245+ connection . client ?. emit ( 'error' , new Error ( 'ECONNREFUSED' ) )
246+ return vi . advanceTimersByTimeAsync ( 1000 )
247+ } )
248+ await Promise . all ( [ rejected , timeout ] )
249+ expect ( mockSubscribe ) . not . toHaveBeenCalled ( )
250+ expect ( connection . client ?. listenerCount ( 'ready' ) ) . toBe ( 1 )
251+ expect ( connection . client ?. listenerCount ( 'error' ) ) . toBe ( 1 )
252+ expect ( connection . client ?. listenerCount ( 'end' ) ) . toBe ( 0 )
253+ expect ( vi . getTimerCount ( ) ) . toBe ( 0 )
254+
255+ connection . status = 'ready'
256+ connection . client ?. emit ( 'ready' )
257+ connection . client ?. emit ( 'message' , 'execution:signal:execution-1' , 'cancelled' )
258+ expect ( handler ) . not . toHaveBeenCalled ( )
259+ await hub . subscribe ( 'execution-1' , handler )
260+ expect ( mockSubscribe ) . toHaveBeenCalledOnce ( )
261+ } finally {
262+ vi . useRealTimers ( )
263+ }
264+ } )
265+
46266 it ( 'waits for one shared subscription acknowledgement before resolving concurrent subscribers' , async ( ) => {
47267 let acknowledge : ( ( ) => void ) | undefined
48268 mockSubscribe . mockReturnValueOnce (
@@ -69,12 +289,12 @@ describe('ExecutionSignalHub', () => {
69289
70290 it ( 'marks every affected subscription unavailable when reconnect acknowledgement fails' , async ( ) => {
71291 const hub = getExecutionSignalHub ( )
72- listeners . get ( 'ready' ) ?. ( )
292+ connection . client ?. emit ( 'ready' )
73293 const handler = vi . fn ( )
74294 await hub . subscribe ( 'execution-1' , handler )
75295 mockSubscribe . mockRejectedValueOnce ( new Error ( 'Redis unavailable' ) )
76296
77- listeners . get ( 'ready' ) ?. ( )
297+ connection . client ?. emit ( 'ready' )
78298
79299 await vi . waitFor ( ( ) => expect ( handler ) . toHaveBeenCalledWith ( 'unavailable' ) )
80300 } )
@@ -87,7 +307,11 @@ describe('ExecutionSignalHub', () => {
87307 await hub . subscribe ( 'execution-2' , secondHandler )
88308
89309 expect ( mockSubscribe ) . toHaveBeenCalledWith ( 'execution:signal:execution-1' , 'execution:cancel' )
90- listeners . get ( 'message' ) ?.( 'execution:cancel' , JSON . stringify ( { executionId : 'execution-1' } ) )
310+ connection . client ?. emit (
311+ 'message' ,
312+ 'execution:cancel' ,
313+ JSON . stringify ( { executionId : 'execution-1' } )
314+ )
91315
92316 expect ( firstHandler ) . toHaveBeenCalledWith ( 'cancelled' )
93317 expect ( secondHandler ) . not . toHaveBeenCalled ( )
@@ -98,19 +322,20 @@ describe('ExecutionSignalHub', () => {
98322 const handler = vi . fn ( )
99323 await hub . subscribe ( 'execution-1' , handler )
100324
101- listeners . get ( 'message' ) ?.(
325+ connection . client ?. emit (
326+ 'message' ,
102327 'execution:cancel' ,
103328 JSON . stringify ( { executionId : 'execution-1' , executionSignalPublished : true } )
104329 )
105- listeners . get ( 'message' ) ?. ( 'execution:signal:execution-1' , 'cancelled' )
330+ connection . client ?. emit ( 'message' , 'execution:signal:execution-1' , 'cancelled' )
106331
107332 expect ( handler ) . toHaveBeenCalledOnce ( )
108333 expect ( handler ) . toHaveBeenCalledWith ( 'cancelled' )
109334 } )
110335
111336 it ( 'does not deliver a stale reconnect failure to a replacement subscriber' , async ( ) => {
112337 const hub = getExecutionSignalHub ( )
113- listeners . get ( 'ready' ) ?. ( )
338+ connection . client ?. emit ( 'ready' )
114339 const oldHandler = vi . fn ( )
115340 const unsubscribeOld = await hub . subscribe ( 'execution-1' , oldHandler )
116341 let rejectOldReconnect ! : ( error : Error ) => void
@@ -120,7 +345,7 @@ describe('ExecutionSignalHub', () => {
120345 } )
121346 )
122347
123- listeners . get ( 'ready' ) ?. ( )
348+ connection . client ?. emit ( 'ready' )
124349 unsubscribeOld ( )
125350 mockSubscribe . mockResolvedValueOnce ( 1 )
126351 const replacement = vi . fn ( )
0 commit comments