@@ -288,6 +288,10 @@ describe('ConnectionFactory', () => {
288288 } ,
289289 ) ,
290290 dial : vi . fn ( async ( ) => ( { } ) ) ,
291+ getConnections : vi . fn ( ( ) => [
292+ { remotePeer : { toString : ( ) => 'relay1' } } ,
293+ { remotePeer : { toString : ( ) => 'relay2' } } ,
294+ ] ) ,
291295 getMultiaddrs : vi . fn ( ( ) => [
292296 { toString : ( ) => '/ip4/127.0.0.1/udp/12345/quic-v1/p2p/test-peer-id' } ,
293297 { toString : ( ) => '/ip4/127.0.0.1/tcp/9001/ws/p2p/test-peer-id' } ,
@@ -551,6 +555,10 @@ describe('ConnectionFactory', () => {
551555 peerId : { toString : ( ) => 'test-peer-id' } ,
552556 addEventListener : mockAddEventListener ,
553557 dialProtocol : vi . fn ( ) ,
558+ getConnections : vi . fn ( ( ) => [
559+ { remotePeer : { toString : ( ) => 'relay1' } } ,
560+ { remotePeer : { toString : ( ) => 'relay2' } } ,
561+ ] ) ,
554562 handle : vi . fn ( ) ,
555563 } ) ) ;
556564
@@ -580,6 +588,10 @@ describe('ConnectionFactory', () => {
580588 peerId : { toString : ( ) => 'test-peer-id' } ,
581589 addEventListener : mockAddEventListener ,
582590 dialProtocol : vi . fn ( ) ,
591+ getConnections : vi . fn ( ( ) => [
592+ { remotePeer : { toString : ( ) => 'relay1' } } ,
593+ { remotePeer : { toString : ( ) => 'relay2' } } ,
594+ ] ) ,
583595 handle : vi . fn ( ) ,
584596 } ) ) ;
585597
@@ -810,6 +822,10 @@ describe('ConnectionFactory', () => {
810822 } ) ;
811823 return stream ;
812824 } ) ,
825+ getConnections : vi . fn ( ( ) => [
826+ { remotePeer : { toString : ( ) => 'relay1' } } ,
827+ { remotePeer : { toString : ( ) => 'relay2' } } ,
828+ ] ) ,
813829 handle : vi . fn ( ) ,
814830 } ) ) ;
815831
@@ -852,6 +868,10 @@ describe('ConnectionFactory', () => {
852868 throw new Error ( 'Connection failed again' ) ;
853869 }
854870 } ) ,
871+ getConnections : vi . fn ( ( ) => [
872+ { remotePeer : { toString : ( ) => 'relay1' } } ,
873+ { remotePeer : { toString : ( ) => 'relay2' } } ,
874+ ] ) ,
855875 handle : vi . fn ( ) ,
856876 } ) ) ;
857877
@@ -872,6 +892,10 @@ describe('ConnectionFactory', () => {
872892 dialProtocol : vi . fn ( async ( ) => {
873893 throw new MuxerClosedError ( 'Muxer closed' ) ;
874894 } ) ,
895+ getConnections : vi . fn ( ( ) => [
896+ { remotePeer : { toString : ( ) => 'relay1' } } ,
897+ { remotePeer : { toString : ( ) => 'relay2' } } ,
898+ ] ) ,
875899 handle : vi . fn ( ) ,
876900 } ) ) ;
877901
@@ -894,6 +918,10 @@ describe('ConnectionFactory', () => {
894918 dialProtocol : vi . fn ( async ( ) => {
895919 throw new TooManyStreams ( 'Too many streams' ) ;
896920 } ) ,
921+ getConnections : vi . fn ( ( ) => [
922+ { remotePeer : { toString : ( ) => 'relay1' } } ,
923+ { remotePeer : { toString : ( ) => 'relay2' } } ,
924+ ] ) ,
897925 handle : vi . fn ( ) ,
898926 } ) ) ;
899927
@@ -928,6 +956,10 @@ describe('ConnectionFactory', () => {
928956 }
929957 throw new Error ( 'Should not reach here' ) ;
930958 } ) ,
959+ getConnections : vi . fn ( ( ) => [
960+ { remotePeer : { toString : ( ) => 'relay1' } } ,
961+ { remotePeer : { toString : ( ) => 'relay2' } } ,
962+ ] ) ,
931963 handle : vi . fn ( ) ,
932964 } ) ) ;
933965
@@ -947,6 +979,10 @@ describe('ConnectionFactory', () => {
947979 dialProtocol : vi . fn ( async ( ) => {
948980 throw new Error ( 'Final connection error' ) ;
949981 } ) ,
982+ getConnections : vi . fn ( ( ) => [
983+ { remotePeer : { toString : ( ) => 'relay1' } } ,
984+ { remotePeer : { toString : ( ) => 'relay2' } } ,
985+ ] ) ,
950986 handle : vi . fn ( ) ,
951987 } ) ) ;
952988
@@ -969,6 +1005,10 @@ describe('ConnectionFactory', () => {
9691005 // eslint-disable-next-line @typescript-eslint/only-throw-error
9701006 throw null ;
9711007 } ) ,
1008+ getConnections : vi . fn ( ( ) => [
1009+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1010+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1011+ ] ) ,
9721012 handle : vi . fn ( ) ,
9731013 } ) ) ;
9741014
@@ -1030,6 +1070,10 @@ describe('ConnectionFactory', () => {
10301070 const stream = { } ;
10311071 return stream ;
10321072 } ) ,
1073+ getConnections : vi . fn ( ( ) => [
1074+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1075+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1076+ ] ) ,
10331077 handle : vi . fn ( ) ,
10341078 } ) ) ;
10351079
@@ -1052,6 +1096,10 @@ describe('ConnectionFactory', () => {
10521096 attemptCount += 1 ;
10531097 throw new Error ( 'Non-retryable error' ) ;
10541098 } ) ,
1099+ getConnections : vi . fn ( ( ) => [
1100+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1101+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1102+ ] ) ,
10551103 handle : vi . fn ( ) ,
10561104 } ) ) ;
10571105
@@ -1113,6 +1161,10 @@ describe('ConnectionFactory', () => {
11131161 }
11141162 return { } ;
11151163 } ) ,
1164+ getConnections : vi . fn ( ( ) => [
1165+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1166+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1167+ ] ) ,
11161168 handle : vi . fn ( ) ,
11171169 } ) ) ;
11181170
@@ -1162,6 +1214,10 @@ describe('ConnectionFactory', () => {
11621214 dialProtocol : vi . fn ( async ( ) => {
11631215 return { } ;
11641216 } ) ,
1217+ getConnections : vi . fn ( ( ) => [
1218+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1219+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1220+ ] ) ,
11651221 handle : vi . fn ( ) ,
11661222 } ) ) ;
11671223
@@ -1214,6 +1270,10 @@ describe('ConnectionFactory', () => {
12141270 dialProtocol : vi . fn ( async ( ) => {
12151271 return { } ;
12161272 } ) ,
1273+ getConnections : vi . fn ( ( ) => [
1274+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1275+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1276+ ] ) ,
12171277 handle : vi . fn ( ) ,
12181278 } ) ) ;
12191279
@@ -1254,6 +1314,10 @@ describe('ConnectionFactory', () => {
12541314 await new Promise ( ( resolve ) => setTimeout ( resolve , 50 ) ) ;
12551315 return { } ;
12561316 } ) ,
1317+ getConnections : vi . fn ( ( ) => [
1318+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1319+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1320+ ] ) ,
12571321 handle : vi . fn ( ) ,
12581322 } ) ) ;
12591323
@@ -1280,6 +1344,10 @@ describe('ConnectionFactory', () => {
12801344 dialCount += 1 ;
12811345 return { } ;
12821346 } ) ,
1347+ getConnections : vi . fn ( ( ) => [
1348+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1349+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1350+ ] ) ,
12831351 handle : vi . fn ( ) ,
12841352 } ) ) ;
12851353
@@ -1305,6 +1373,10 @@ describe('ConnectionFactory', () => {
13051373 dialCount += 1 ;
13061374 return { } ;
13071375 } ) ,
1376+ getConnections : vi . fn ( ( ) => [
1377+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1378+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1379+ ] ) ,
13081380 handle : vi . fn ( ) ,
13091381 } ) ) ;
13101382
@@ -1335,6 +1407,10 @@ describe('ConnectionFactory', () => {
13351407 }
13361408 return { } ;
13371409 } ) ,
1410+ getConnections : vi . fn ( ( ) => [
1411+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1412+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1413+ ] ) ,
13381414 handle : vi . fn ( ) ,
13391415 } ) ) ;
13401416
@@ -1353,6 +1429,10 @@ describe('ConnectionFactory', () => {
13531429 dialProtocol : vi . fn ( async ( ) => {
13541430 throw new Error ( 'Dial failed' ) ;
13551431 } ) ,
1432+ getConnections : vi . fn ( ( ) => [
1433+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1434+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1435+ ] ) ,
13561436 handle : vi . fn ( ) ,
13571437 } ) ) ;
13581438
@@ -1380,6 +1460,10 @@ describe('ConnectionFactory', () => {
13801460 dialProtocol : vi . fn ( async ( ) => {
13811461 throw new Error ( 'test error' ) ;
13821462 } ) ,
1463+ getConnections : vi . fn ( ( ) => [
1464+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1465+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1466+ ] ) ,
13831467 handle : vi . fn ( ) ,
13841468 } ) ) ;
13851469
@@ -1405,6 +1489,10 @@ describe('ConnectionFactory', () => {
14051489 // eslint-disable-next-line @typescript-eslint/only-throw-error
14061490 throw null ;
14071491 } ) ,
1492+ getConnections : vi . fn ( ( ) => [
1493+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1494+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1495+ ] ) ,
14081496 handle : vi . fn ( ) ,
14091497 } ) ) ;
14101498
@@ -1430,6 +1518,10 @@ describe('ConnectionFactory', () => {
14301518 peerId : { toString : ( ) => 'test-peer' } ,
14311519 addEventListener : vi . fn ( ) ,
14321520 dialProtocol : vi . fn ( ) ,
1521+ getConnections : vi . fn ( ( ) => [
1522+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1523+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1524+ ] ) ,
14331525 handle : vi . fn ( ) ,
14341526 } ) ) ;
14351527
@@ -1483,6 +1575,10 @@ describe('ConnectionFactory', () => {
14831575 peerId : { toString : ( ) => 'test-peer' } ,
14841576 addEventListener : vi . fn ( ) ,
14851577 dialProtocol : vi . fn ( ) ,
1578+ getConnections : vi . fn ( ( ) => [
1579+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1580+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1581+ ] ) ,
14861582 handle : vi . fn ( ) ,
14871583 } ) ) ;
14881584
@@ -1507,6 +1603,7 @@ describe('ConnectionFactory', () => {
15071603 peerId : { toString : ( ) => 'test-peer' } ,
15081604 addEventListener : vi . fn ( ) ,
15091605 dialProtocol : vi . fn ( ) ,
1606+ dial : vi . fn ( ) . mockResolvedValue ( { } ) ,
15101607 handle : vi . fn ( ) ,
15111608 getConnections : vi . fn ( ( ) => [ ] ) ,
15121609 } ) ) ;
@@ -1524,6 +1621,7 @@ describe('ConnectionFactory', () => {
15241621 peerId : { toString : ( ) => 'test-peer' } ,
15251622 addEventListener : vi . fn ( ) ,
15261623 dialProtocol : vi . fn ( ) ,
1624+ dial : vi . fn ( ) . mockResolvedValue ( { } ) ,
15271625 handle : vi . fn ( ) ,
15281626 getConnections : vi . fn ( ( ) => [ ] ) ,
15291627 } ) ) ;
@@ -1664,8 +1762,15 @@ describe('ConnectionFactory', () => {
16641762 * Create a libp2p mock that captures event listeners and exposes a dial spy.
16651763 *
16661764 * @param mockDial - The dial mock to use.
1765+ * @param getConnections - Mock for getConnections; defaults to returning both relays as connected.
16671766 */
1668- function setupRelayMock ( mockDial : ReturnType < typeof vi . fn > ) {
1767+ function setupRelayMock (
1768+ mockDial : ReturnType < typeof vi . fn > ,
1769+ getConnections : ReturnType < typeof vi . fn > = vi . fn ( ( ) => [
1770+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1771+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1772+ ] ) ,
1773+ ) {
16691774 createLibp2p . mockImplementation ( async ( ) => ( {
16701775 start : vi . fn ( ) ,
16711776 stop : vi . fn ( ) ,
@@ -1680,6 +1785,7 @@ describe('ConnectionFactory', () => {
16801785 dialProtocol : vi . fn ( async ( ) => ( { } ) ) ,
16811786 handle : vi . fn ( ) ,
16821787 dial : mockDial ,
1788+ getConnections,
16831789 } ) ) ;
16841790 }
16851791
@@ -1792,6 +1898,10 @@ describe('ConnectionFactory', () => {
17921898 ) ,
17931899 getMultiaddrs : vi . fn ( ( ) => [ ] ) ,
17941900 dialProtocol : vi . fn ( async ( ) => ( { } ) ) ,
1901+ getConnections : vi . fn ( ( ) => [
1902+ { remotePeer : { toString : ( ) => 'relay1' } } ,
1903+ { remotePeer : { toString : ( ) => 'relay2' } } ,
1904+ ] ) ,
17951905 handle : vi . fn ( ) ,
17961906 dial : mockDial ,
17971907 } ) ) ;
@@ -1882,6 +1992,77 @@ describe('ConnectionFactory', () => {
18821992 expect ( pendingTimers ) . toHaveLength ( 1 ) ;
18831993 } ) ;
18841994
1995+ it ( 'schedules reconnect for relay not connected on startup' , async ( ) => {
1996+ const mockDial = vi . fn ( ) . mockResolvedValue ( { } ) ;
1997+ // relay2 is connected, relay1 is not
1998+ setupRelayMock (
1999+ mockDial ,
2000+ vi . fn ( ( ) => [ { remotePeer : { toString : ( ) => 'relay2' } } ] ) ,
2001+ ) ;
2002+
2003+ factory = await createFactory ( ) ;
2004+
2005+ expect ( pendingTimers ) . toHaveLength ( 1 ) ;
2006+ expect ( mockLoggerLog ) . toHaveBeenCalledWith (
2007+ 'relay relay1 not connected after startup, scheduling reconnect' ,
2008+ ) ;
2009+ } ) ;
2010+
2011+ it ( 'schedules reconnect for all relays when none are connected on startup' , async ( ) => {
2012+ const mockDial = vi . fn ( ) . mockResolvedValue ( { } ) ;
2013+ setupRelayMock (
2014+ mockDial ,
2015+ vi . fn ( ( ) => [ ] ) ,
2016+ ) ;
2017+
2018+ factory = await createFactory ( ) ;
2019+
2020+ expect ( pendingTimers ) . toHaveLength ( 2 ) ;
2021+ } ) ;
2022+
2023+ it ( 'recovers relay that was down on startup once it comes back up' , async ( ) => {
2024+ const mockDial = vi . fn ( ) . mockResolvedValue ( { } ) ;
2025+ setupRelayMock (
2026+ mockDial ,
2027+ vi . fn ( ( ) => [ ] ) ,
2028+ ) ;
2029+
2030+ factory = await createFactory ( ) ;
2031+
2032+ // Two startup reconnect timers (relay1 and relay2)
2033+ expect ( pendingTimers ) . toHaveLength ( 2 ) ;
2034+
2035+ await runAllTimers ( ) ;
2036+
2037+ expect ( mockDial ) . toHaveBeenCalledTimes ( 2 ) ;
2038+ expect ( mockLoggerLog ) . toHaveBeenCalledWith (
2039+ expect . stringContaining ( 'relay relay1 reconnected' ) ,
2040+ ) ;
2041+ expect ( mockLoggerLog ) . toHaveBeenCalledWith (
2042+ expect . stringContaining ( 'relay relay2 reconnected' ) ,
2043+ ) ;
2044+ } ) ;
2045+
2046+ it ( 'does not schedule duplicate startup reconnect when relay also fires connection:close' , async ( ) => {
2047+ const mockDial = vi . fn ( ) . mockResolvedValue ( { } ) ;
2048+ // relay1 not connected on startup
2049+ setupRelayMock (
2050+ mockDial ,
2051+ vi . fn ( ( ) => [ { remotePeer : { toString : ( ) => 'relay2' } } ] ) ,
2052+ ) ;
2053+
2054+ factory = await createFactory ( ) ;
2055+
2056+ // 1 timer from startup reconnect for relay1
2057+ expect ( pendingTimers ) . toHaveLength ( 1 ) ;
2058+
2059+ // connection:close for relay1 fires while startup reconnect is pending
2060+ fireConnectionClose ( 'relay1' ) ;
2061+
2062+ // Still just 1 timer — #scheduleRelayReconnect deduplicates
2063+ expect ( pendingTimers ) . toHaveLength ( 1 ) ;
2064+ } ) ;
2065+
18852066 it ( 'does not leak timer when stop() runs during in-flight dial' , async ( ) => {
18862067 // Dial rejects after stop() has already completed both cleanup passes.
18872068 // The catch block's recursive #reconnectRelay call must not schedule a
0 commit comments