@@ -70,14 +70,16 @@ class FakeSocket extends EventEmitter {
7070class TestGatewayPlugin extends GatewayPlugin {
7171 sockets : FakeSocket [ ] = [ ] ;
7272 connectCalls : boolean [ ] = [ ] ;
73+ urls : string [ ] = [ ] ;
7374
7475 override connect ( resume = false ) : void {
7576 this . connectCalls . push ( resume ) ;
7677 super . connect ( resume ) ;
7778 }
7879
79- protected override createWebSocket ( ) : never {
80+ protected override createWebSocket ( url : string ) : never {
8081 const socket = new FakeSocket ( ) ;
82+ this . urls . push ( url ) ;
8183 this . sockets . push ( socket ) ;
8284 return socket as never ;
8385 }
@@ -211,7 +213,7 @@ describe("GatewayPlugin", () => {
211213 originalSocket ?. emit ( "close" , 1006 ) ;
212214
213215 await vi . advanceTimersByTimeAsync ( 2_000 ) ;
214- expect ( gateway . connectCalls ) . toEqual ( [ false , true ] ) ;
216+ expect ( gateway . connectCalls ) . toEqual ( [ false , false ] ) ;
215217 const replacementSocket = gateway . sockets [ 1 ] ;
216218 replacementSocket ?. emit ( "open" ) ;
217219
@@ -347,6 +349,7 @@ describe("GatewayPlugin", () => {
347349 } ;
348350 gateway . isConnected = false ;
349351 ( gateway as unknown as { reconnectAttempts : number } ) . reconnectAttempts = 7 ;
352+ ( gateway as unknown as { consecutiveResumeFailures : number } ) . consecutiveResumeFailures = 2 ;
350353
351354 await (
352355 gateway as unknown as {
@@ -359,6 +362,9 @@ describe("GatewayPlugin", () => {
359362
360363 expect ( gateway . isConnected ) . toBe ( true ) ;
361364 expect ( ( gateway as unknown as { reconnectAttempts : number } ) . reconnectAttempts ) . toBe ( 0 ) ;
365+ expect (
366+ ( gateway as unknown as { consecutiveResumeFailures : number } ) . consecutiveResumeFailures ,
367+ ) . toBe ( 0 ) ;
362368 } ) ;
363369
364370 it ( "queues outbound gateway events when the connection window is exhausted" , ( ) => {
@@ -462,22 +468,133 @@ describe("GatewayPlugin", () => {
462468 clearInterval ( heartbeat ) ;
463469 } ) ;
464470
465- it ( "reconnects after active remote normal closes " , async ( ) => {
471+ it ( "logs and re-identifies after a resumable close without session state " , async ( ) => {
466472 vi . useFakeTimers ( ) ;
467473 const gateway = new TestGatewayPlugin ( {
468474 autoInteractions : false ,
469475 url : "wss://gateway.example.test" ,
470476 } ) ;
477+ const debugSpy = vi . fn ( ) ;
478+ gateway . emitter . on ( "debug" , debugSpy ) ;
471479
472480 gateway . connect ( false ) ;
473481 gateway . sockets [ 0 ] ?. emit ( "open" ) ;
474482 gateway . sockets [ 0 ] ?. emit ( "close" , 1000 ) ;
475483
476484 expect ( gateway . sockets ) . toHaveLength ( 1 ) ;
485+ expect ( debugSpy ) . toHaveBeenCalledWith (
486+ "Gateway reconnect scheduled in 2000ms (close, resume=false)" ,
487+ ) ;
477488 await vi . advanceTimersByTimeAsync ( 2_000 ) ;
478489
479- expect ( gateway . connectCalls ) . toEqual ( [ false , true ] ) ;
490+ expect ( gateway . connectCalls ) . toEqual ( [ false , false ] ) ;
480491 expect ( gateway . sockets ) . toHaveLength ( 2 ) ;
492+ const reconnectSocket = gateway . sockets [ 1 ] ;
493+ reconnectSocket ?. emit ( "open" ) ;
494+ reconnectSocket ?. emit (
495+ "message" ,
496+ JSON . stringify ( {
497+ op : GatewayOpcodes . Hello ,
498+ d : { heartbeat_interval : 45_000 } ,
499+ s : null ,
500+ } ) ,
501+ ) ;
502+ await vi . advanceTimersByTimeAsync ( 0 ) ;
503+ expect ( sentGatewayOpcodes ( reconnectSocket ?. send ?? vi . fn ( ) ) ) . toContain ( GatewayOpcodes . Identify ) ;
504+ expect ( sentGatewayOpcodes ( reconnectSocket ?. send ?? vi . fn ( ) ) ) . not . toContain (
505+ GatewayOpcodes . Resume ,
506+ ) ;
507+ } ) ;
508+
509+ it ( "falls back to a fresh IDENTIFY after three failed resume attempts" , async ( ) => {
510+ vi . useFakeTimers ( ) ;
511+ vi . setSystemTime ( 0 ) ;
512+ const gateway = new TestGatewayPlugin ( {
513+ autoInteractions : false ,
514+ url : "wss://gateway.example.test" ,
515+ } ) ;
516+ const debugSpy = vi . fn ( ) ;
517+ gateway . emitter . on ( "debug" , debugSpy ) ;
518+ ( gateway as unknown as { client : unknown } ) . client = {
519+ options : { token : "token" } ,
520+ dispatchGatewayEvent : vi . fn ( async ( ) => { } ) ,
521+ } ;
522+
523+ gateway . connect ( false ) ;
524+ const initialSocket = gateway . sockets [ 0 ] ;
525+ initialSocket ?. emit ( "open" ) ;
526+ initialSocket ?. emit (
527+ "message" ,
528+ JSON . stringify ( {
529+ op : GatewayOpcodes . Hello ,
530+ d : { heartbeat_interval : 45_000 } ,
531+ s : null ,
532+ } ) ,
533+ ) ;
534+ await vi . advanceTimersByTimeAsync ( 0 ) ;
535+ expect ( sentGatewayOpcodes ( initialSocket ?. send ?? vi . fn ( ) ) ) . toContain ( GatewayOpcodes . Identify ) ;
536+ initialSocket ?. emit (
537+ "message" ,
538+ JSON . stringify ( {
539+ op : GatewayOpcodes . Dispatch ,
540+ t : GatewayDispatchEvents . Ready ,
541+ s : 42 ,
542+ d : {
543+ session_id : "session-1" ,
544+ resume_gateway_url : "wss://resume.example.test" ,
545+ } ,
546+ } ) ,
547+ ) ;
548+ await vi . advanceTimersByTimeAsync ( 0 ) ;
549+
550+ for ( const delayMs of [ 2_000 , 4_000 , 8_000 ] ) {
551+ gateway . sockets . at ( - 1 ) ?. emit ( "close" , 1006 ) ;
552+ await vi . advanceTimersByTimeAsync ( delayMs ) ;
553+ const resumeSocket = gateway . sockets . at ( - 1 ) ;
554+ expect ( gateway . urls . at ( - 1 ) ) . toMatch ( / ^ w s s : \/ \/ r e s u m e \. e x a m p l e \. t e s t \/ / ) ;
555+ resumeSocket ?. emit ( "open" ) ;
556+ resumeSocket ?. emit (
557+ "message" ,
558+ JSON . stringify ( {
559+ op : GatewayOpcodes . Hello ,
560+ d : { heartbeat_interval : 45_000 } ,
561+ s : null ,
562+ } ) ,
563+ ) ;
564+ expect ( sentGatewayOpcodes ( resumeSocket ?. send ?? vi . fn ( ) ) ) . toContain ( GatewayOpcodes . Resume ) ;
565+ expect ( debugSpy ) . toHaveBeenCalledWith (
566+ `Gateway reconnect scheduled in ${ delayMs } ms (close, resume=true)` ,
567+ ) ;
568+ }
569+
570+ gateway . sockets . at ( - 1 ) ?. emit ( "close" , 1006 ) ;
571+ expect ( debugSpy ) . toHaveBeenCalledWith (
572+ "Gateway forcing fresh IDENTIFY after 3 failed resume attempts" ,
573+ ) ;
574+ expect ( debugSpy ) . toHaveBeenCalledWith (
575+ "Gateway reconnect scheduled in 16000ms (close, resume=false)" ,
576+ ) ;
577+
578+ await vi . advanceTimersByTimeAsync ( 16_000 ) ;
579+ const freshSocket = gateway . sockets . at ( - 1 ) ;
580+ expect ( gateway . urls . at ( - 1 ) ) . toMatch ( / ^ w s s : \/ \/ g a t e w a y \. e x a m p l e \. t e s t \/ / ) ;
581+ freshSocket ?. emit ( "open" ) ;
582+ freshSocket ?. emit (
583+ "message" ,
584+ JSON . stringify ( {
585+ op : GatewayOpcodes . Hello ,
586+ d : { heartbeat_interval : 45_000 } ,
587+ s : null ,
588+ } ) ,
589+ ) ;
590+ await vi . advanceTimersByTimeAsync ( 0 ) ;
591+
592+ expect ( sentGatewayOpcodes ( freshSocket ?. send ?? vi . fn ( ) ) ) . toContain ( GatewayOpcodes . Identify ) ;
593+ expect ( sentGatewayOpcodes ( freshSocket ?. send ?? vi . fn ( ) ) ) . not . toContain ( GatewayOpcodes . Resume ) ;
594+ const sessionState = gatewaySessionState ( gateway ) ;
595+ expect ( sessionState . sessionId ) . toBeNull ( ) ;
596+ expect ( sessionState . resumeGatewayUrl ) . toBeNull ( ) ;
597+ expect ( sessionState . sequence ) . toBeNull ( ) ;
481598 } ) ;
482599
483600 it . each ( [ GatewayCloseCodes . InvalidSeq , GatewayCloseCodes . AlreadyAuthenticated ] ) (
@@ -546,7 +663,7 @@ describe("GatewayPlugin", () => {
546663 expect ( gateway . connectCalls ) . toEqual ( [ false ] ) ;
547664
548665 await vi . advanceTimersByTimeAsync ( 1 ) ;
549- expect ( gateway . connectCalls ) . toEqual ( [ false , true ] ) ;
666+ expect ( gateway . connectCalls ) . toEqual ( [ false , false ] ) ;
550667 } ) ;
551668
552669 it ( "includes close code details when reconnect attempts are exhausted" , async ( ) => {
0 commit comments