Skip to content

Commit a0c1560

Browse files
sirtimidclaude
andauthored
fix(ocap-kernel): schedule relay reconnect for relays unreachable on startup (#918)
Closes #899 ## Summary When a known relay is down at kernel startup, libp2p starts successfully but never emits a `connection:close` event for it — so the existing exponential-backoff reconnection logic never triggers and the relay remains unreachable until kernel restart. The fix adds a startup check in `ConnectionFactory#init()` after `libp2p.start()`: it calls `getConnections()` and schedules reconnection for any known relay not yet present in the result. This reuses `#scheduleRelayReconnect` identically to the `connection:close` path, so both startup failures and post-connection dropouts go through the same backoff logic (base 5s, max 60s, max 10 attempts). - Startup check uses `getConnections()` (no awaits introduced, no race with `connection:close`) - `#scheduleRelayReconnect`'s deduplication guard (`#pendingRelayReconnects.has`) ensures a simultaneous `connection:close` event doesn't double-schedule - All existing `#stopped` / `#signal.aborted` guards in `#scheduleRelayReconnect` remain meaningful ## Tests Four new unit tests cover the startup reconnect path: one relay down, all relays down, end-to-end recovery, and deduplication with a simultaneous `connection:close` event. All 24 existing inline libp2p mocks were updated to include `getConnections` (returning both relays as connected by default, so no existing test behaviour changes). 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes libp2p startup behavior to proactively schedule relay reconnect attempts based on `getConnections()`, which could increase initial dial traffic and affects connectivity/timer behavior in a network-critical component. > > **Overview** > Ensures relay reconnection backoff also triggers when a known relay is *unreachable at kernel startup* (i.e., no `connection:close` event is emitted). After `libp2p.start()`, `ConnectionFactory#init()` now checks `libp2p.getConnections()` and schedules `#scheduleRelayReconnect` for any configured relay peerId not currently connected. > > Updates the unit test suite to include `getConnections` in all libp2p mocks and adds new tests covering startup reconnect scheduling, recovery when relays come back, and deduplication when a startup reconnect overlaps with a `connection:close` event. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7723296. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fb47068 commit a0c1560

2 files changed

Lines changed: 195 additions & 1 deletion

File tree

packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)