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,140 @@ 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 . each ( [ 'error' , 'end' ] ) (
128+ 'rejects readiness waiters on %s and allows a fresh attempt' ,
129+ async ( event ) => {
130+ connection . status = 'connect'
131+ const hub = getExecutionSignalHub ( )
132+ const handler = vi . fn ( )
133+ const subscription = hub . subscribe ( 'execution-1' , handler )
134+ const rejected = expect ( subscription ) . rejects . toThrow ( 'Execution signal subscription failed:' )
135+
136+ connection . status = 'end'
137+ connection . client ?. emit ( event , new Error ( 'connection failed' ) )
138+ await rejected
139+ expect ( mockSubscribe ) . not . toHaveBeenCalled ( )
140+ expect ( connection . client ?. listenerCount ( 'ready' ) ) . toBe ( 1 )
141+ expect ( connection . client ?. listenerCount ( 'error' ) ) . toBe ( 1 )
142+ expect ( connection . client ?. listenerCount ( 'end' ) ) . toBe ( 0 )
143+
144+ connection . status = 'ready'
145+ connection . client ?. emit ( 'ready' )
146+ await hub . subscribe ( 'execution-1' , handler )
147+ expect ( mockSubscribe ) . toHaveBeenCalledOnce ( )
148+ }
149+ )
150+
151+ it ( 'bounds the readiness wait and removes failed handlers before a later ready event' , async ( ) => {
152+ vi . useFakeTimers ( )
153+ try {
154+ connection . status = 'connect'
155+ const hub = getExecutionSignalHub ( )
156+ const handler = vi . fn ( )
157+ const subscription = hub . subscribe ( 'execution-1' , handler )
158+ const rejected = expect ( subscription ) . rejects . toThrow (
159+ 'Timed out waiting for Redis subscriber readiness'
160+ )
161+
162+ await Promise . all ( [ rejected , vi . advanceTimersByTimeAsync ( 5000 ) ] )
163+ expect ( mockSubscribe ) . not . toHaveBeenCalled ( )
164+ expect ( connection . client ?. listenerCount ( 'ready' ) ) . toBe ( 1 )
165+ expect ( connection . client ?. listenerCount ( 'error' ) ) . toBe ( 1 )
166+ expect ( connection . client ?. listenerCount ( 'end' ) ) . toBe ( 0 )
167+ expect ( vi . getTimerCount ( ) ) . toBe ( 0 )
168+
169+ connection . status = 'ready'
170+ connection . client ?. emit ( 'ready' )
171+ connection . client ?. emit ( 'message' , 'execution:signal:execution-1' , 'cancelled' )
172+ expect ( handler ) . not . toHaveBeenCalled ( )
173+ await hub . subscribe ( 'execution-1' , handler )
174+ expect ( mockSubscribe ) . toHaveBeenCalledOnce ( )
175+ } finally {
176+ vi . useRealTimers ( )
177+ }
178+ } )
179+
46180 it ( 'waits for one shared subscription acknowledgement before resolving concurrent subscribers' , async ( ) => {
47181 let acknowledge : ( ( ) => void ) | undefined
48182 mockSubscribe . mockReturnValueOnce (
@@ -69,12 +203,12 @@ describe('ExecutionSignalHub', () => {
69203
70204 it ( 'marks every affected subscription unavailable when reconnect acknowledgement fails' , async ( ) => {
71205 const hub = getExecutionSignalHub ( )
72- listeners . get ( 'ready' ) ?. ( )
206+ connection . client ?. emit ( 'ready' )
73207 const handler = vi . fn ( )
74208 await hub . subscribe ( 'execution-1' , handler )
75209 mockSubscribe . mockRejectedValueOnce ( new Error ( 'Redis unavailable' ) )
76210
77- listeners . get ( 'ready' ) ?. ( )
211+ connection . client ?. emit ( 'ready' )
78212
79213 await vi . waitFor ( ( ) => expect ( handler ) . toHaveBeenCalledWith ( 'unavailable' ) )
80214 } )
@@ -87,7 +221,11 @@ describe('ExecutionSignalHub', () => {
87221 await hub . subscribe ( 'execution-2' , secondHandler )
88222
89223 expect ( mockSubscribe ) . toHaveBeenCalledWith ( 'execution:signal:execution-1' , 'execution:cancel' )
90- listeners . get ( 'message' ) ?.( 'execution:cancel' , JSON . stringify ( { executionId : 'execution-1' } ) )
224+ connection . client ?. emit (
225+ 'message' ,
226+ 'execution:cancel' ,
227+ JSON . stringify ( { executionId : 'execution-1' } )
228+ )
91229
92230 expect ( firstHandler ) . toHaveBeenCalledWith ( 'cancelled' )
93231 expect ( secondHandler ) . not . toHaveBeenCalled ( )
@@ -98,19 +236,20 @@ describe('ExecutionSignalHub', () => {
98236 const handler = vi . fn ( )
99237 await hub . subscribe ( 'execution-1' , handler )
100238
101- listeners . get ( 'message' ) ?.(
239+ connection . client ?. emit (
240+ 'message' ,
102241 'execution:cancel' ,
103242 JSON . stringify ( { executionId : 'execution-1' , executionSignalPublished : true } )
104243 )
105- listeners . get ( 'message' ) ?. ( 'execution:signal:execution-1' , 'cancelled' )
244+ connection . client ?. emit ( 'message' , 'execution:signal:execution-1' , 'cancelled' )
106245
107246 expect ( handler ) . toHaveBeenCalledOnce ( )
108247 expect ( handler ) . toHaveBeenCalledWith ( 'cancelled' )
109248 } )
110249
111250 it ( 'does not deliver a stale reconnect failure to a replacement subscriber' , async ( ) => {
112251 const hub = getExecutionSignalHub ( )
113- listeners . get ( 'ready' ) ?. ( )
252+ connection . client ?. emit ( 'ready' )
114253 const oldHandler = vi . fn ( )
115254 const unsubscribeOld = await hub . subscribe ( 'execution-1' , oldHandler )
116255 let rejectOldReconnect ! : ( error : Error ) => void
@@ -120,7 +259,7 @@ describe('ExecutionSignalHub', () => {
120259 } )
121260 )
122261
123- listeners . get ( 'ready' ) ?. ( )
262+ connection . client ?. emit ( 'ready' )
124263 unsubscribeOld ( )
125264 mockSubscribe . mockResolvedValueOnce ( 1 )
126265 const replacement = vi . fn ( )
0 commit comments