@serfab/cadre-core 0.13.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +20 -6
  2. package/dist/bootstrap-peer-store-file.js +2 -2
  3. package/dist/bootstrap-peer-store.d.ts +32 -12
  4. package/dist/bootstrap-peer-store.js +41 -15
  5. package/dist/bootstrap-peer-store.js.map +1 -1
  6. package/dist/cadre-node.d.ts +558 -80
  7. package/dist/cadre-node.js +1060 -170
  8. package/dist/cadre-node.js.map +1 -1
  9. package/dist/control-authorization.d.ts +2 -2
  10. package/dist/control-authorization.js +1 -0
  11. package/dist/control-authorization.js.map +1 -1
  12. package/dist/control-cohort.d.ts +13 -26
  13. package/dist/control-cohort.js +0 -26
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +299 -44
  16. package/dist/control-database.js +469 -69
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-read-retry.d.ts +40 -11
  19. package/dist/control-read-retry.js +48 -15
  20. package/dist/control-read-retry.js.map +1 -1
  21. package/dist/control-retry.d.ts +58 -3
  22. package/dist/control-retry.js +58 -3
  23. package/dist/control-retry.js.map +1 -1
  24. package/dist/control-schema.d.ts +1 -1
  25. package/dist/control-schema.js +103 -19
  26. package/dist/control-schema.js.map +1 -1
  27. package/dist/control-write-retry.d.ts +80 -11
  28. package/dist/control-write-retry.js +168 -29
  29. package/dist/control-write-retry.js.map +1 -1
  30. package/dist/enrolled-machine-store.js +2 -2
  31. package/dist/index.d.ts +14 -7
  32. package/dist/index.js +33 -6
  33. package/dist/index.js.map +1 -1
  34. package/dist/node-local-snapshot.d.ts +15 -7
  35. package/dist/node-local-snapshot.js +23 -8
  36. package/dist/node-local-snapshot.js.map +1 -1
  37. package/dist/peer-dial.d.ts +143 -0
  38. package/dist/peer-dial.js +176 -0
  39. package/dist/peer-dial.js.map +1 -0
  40. package/dist/relay-addrs.d.ts +90 -52
  41. package/dist/relay-addrs.js +91 -59
  42. package/dist/relay-addrs.js.map +1 -1
  43. package/dist/relay-reservation.d.ts +71 -3
  44. package/dist/relay-reservation.js +116 -8
  45. package/dist/relay-reservation.js.map +1 -1
  46. package/dist/seed-bootstrap.d.ts +30 -1
  47. package/dist/seed-bootstrap.js +62 -17
  48. package/dist/seed-bootstrap.js.map +1 -1
  49. package/dist/storage-scope.d.ts +62 -0
  50. package/dist/storage-scope.js +75 -0
  51. package/dist/storage-scope.js.map +1 -0
  52. package/dist/strand-database.d.ts +34 -16
  53. package/dist/strand-database.js +38 -24
  54. package/dist/strand-database.js.map +1 -1
  55. package/dist/strand-first-sync-gate.d.ts +152 -0
  56. package/dist/strand-first-sync-gate.js +208 -0
  57. package/dist/strand-first-sync-gate.js.map +1 -0
  58. package/dist/strand-formation-manager.d.ts +87 -5
  59. package/dist/strand-formation-manager.js +109 -5
  60. package/dist/strand-formation-manager.js.map +1 -1
  61. package/dist/strand-formation-protocol.d.ts +21 -1
  62. package/dist/strand-formation-protocol.js +30 -0
  63. package/dist/strand-formation-protocol.js.map +1 -1
  64. package/dist/strand-instance-manager.d.ts +243 -6
  65. package/dist/strand-instance-manager.js +470 -37
  66. package/dist/strand-instance-manager.js.map +1 -1
  67. package/dist/strand-member-key.d.ts +20 -12
  68. package/dist/strand-member-key.js +20 -12
  69. package/dist/strand-member-key.js.map +1 -1
  70. package/dist/strand-member-registry.js +3 -0
  71. package/dist/strand-member-registry.js.map +1 -1
  72. package/dist/strand-membership-reconciler.d.ts +361 -0
  73. package/dist/strand-membership-reconciler.js +548 -0
  74. package/dist/strand-membership-reconciler.js.map +1 -0
  75. package/dist/strand-membership-writer.d.ts +131 -11
  76. package/dist/strand-membership-writer.js +396 -134
  77. package/dist/strand-membership-writer.js.map +1 -1
  78. package/dist/strand-network-config.d.ts +46 -14
  79. package/dist/strand-network-config.js +79 -23
  80. package/dist/strand-network-config.js.map +1 -1
  81. package/dist/strand-solicitation.d.ts +10 -1
  82. package/dist/strand-solicitation.js +5 -0
  83. package/dist/strand-solicitation.js.map +1 -1
  84. package/dist/strand-wake-protocol.d.ts +11 -7
  85. package/dist/strand-wake-protocol.js +20 -52
  86. package/dist/strand-wake-protocol.js.map +1 -1
  87. package/dist/strand-watcher.js +6 -0
  88. package/dist/strand-watcher.js.map +1 -1
  89. package/dist/timed-step.d.ts +12 -0
  90. package/dist/timed-step.js +28 -0
  91. package/dist/timed-step.js.map +1 -0
  92. package/dist/timeout-scheduler.d.ts +19 -0
  93. package/dist/timeout-scheduler.js +22 -0
  94. package/dist/timeout-scheduler.js.map +1 -0
  95. package/dist/types.d.ts +262 -30
  96. package/dist/types.js.map +1 -1
  97. package/package.json +8 -8
  98. package/src/bootstrap-peer-store-file.ts +2 -2
  99. package/src/bootstrap-peer-store.ts +69 -27
  100. package/src/cadre-node.ts +7301 -6353
  101. package/src/control-authorization.ts +2 -1
  102. package/src/control-cohort.ts +122 -130
  103. package/src/control-database.ts +537 -69
  104. package/src/control-read-retry.ts +202 -167
  105. package/src/control-retry.ts +297 -190
  106. package/src/control-schema.ts +103 -19
  107. package/src/control-write-retry.ts +525 -383
  108. package/src/enrolled-machine-store.ts +2 -2
  109. package/src/index.ts +83 -6
  110. package/src/node-local-snapshot.ts +25 -8
  111. package/src/peer-dial.ts +215 -0
  112. package/src/relay-addrs.ts +458 -436
  113. package/src/relay-reservation.ts +951 -828
  114. package/src/seed-bootstrap.ts +76 -18
  115. package/src/storage-scope.ts +78 -0
  116. package/src/strand-database.ts +50 -27
  117. package/src/strand-first-sync-gate.ts +258 -0
  118. package/src/strand-formation-manager.ts +140 -5
  119. package/src/strand-formation-protocol.ts +43 -1
  120. package/src/strand-instance-manager.ts +577 -37
  121. package/src/strand-member-key.ts +20 -12
  122. package/src/strand-member-registry.ts +3 -0
  123. package/src/strand-membership-reconciler.ts +680 -0
  124. package/src/strand-membership-writer.ts +527 -189
  125. package/src/strand-network-config.ts +102 -24
  126. package/src/strand-solicitation.ts +16 -1
  127. package/src/strand-wake-protocol.ts +21 -66
  128. package/src/strand-watcher.ts +6 -0
  129. package/src/timed-step.ts +28 -0
  130. package/src/timeout-scheduler.ts +28 -0
  131. package/src/types.ts +1789 -1554
@@ -1,828 +1,951 @@
1
- /**
2
- * Relay reservation for nodes that reserve through the SEARCH listen addr — the
3
- * dial, the explicit reservation request, the retry loop that keeps it, and the
4
- * (live) status derivation.
5
- *
6
- * `@libp2p/circuit-relay-v2`'s listener branches on the SHAPE of the listen
7
- * address:
8
- *
9
- * | listen addr | libp2p behaviour | unreachable relay |
10
- * | ------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------- |
11
- * | `<dial addr>/p2p/<relayPeerId>/p2p-circuit` | CONFIGURED reservation: dial that exact relay and reserve, or fail | `listen()` throws |
12
- * | bare `/p2p-circuit` | SEARCH mode: register a pending reservation, to be filled by relay discovery | nothing throws, no reserve |
13
- *
14
- * A search listener needs someone to (a) dial the relay and (b) fill the pending
15
- * reservation it created, then report whether one actually landed. That is this
16
- * module. It is **fail-soft by construction**: nothing here throws.
17
- *
18
- * EVERY cadre CONTROL node now takes that route — `network.relayAddrs` resolves
19
- * to the bare search entry (`relay-addrs.ts` → `RelayListenRoute`) and
20
- * `CadreNode.start()` drives this module once the control database is up. The
21
- * configured shape would dial the relay from inside `libp2p.start()`, putting a
22
- * sibling in this node's cohort before its own control database existed, and a
23
- * sibling that has not yet replicated this node's `CadrePeer` row correctly
24
- * refuses its control-DB streams — which killed `start()` outright.
25
- *
26
- * So the two shapes are no longer alternative ROUTES, only alternative failure
27
- * postures on top of the same drive: a node that names `network.relayAddrs` gets
28
- * a `RelayReservationFailedError` out of `start()` when the first attempt lands
29
- * nothing (fail-fast, the operator asked for this relay), while a node that calls
30
- * `CadreNode.reserveRelays()` itself gets a non-`reserved` status and stays up
31
- * (fail-soft, the browser-tab posture). Configuring both is now redundant, not
32
- * fatal. STRAND nodes still take the configured shape — see `relay-addrs.ts`.
33
- *
34
- * ⚠️ WHY THE RESERVATION IS REQUESTED EXPLICITLY RATHER THAN LEFT TO DISCOVERY.
35
- * libp2p fills a search listener's pending reservation from `RelayDiscovery`,
36
- * which nominates a peer only once it can see the relay-hop protocol id in that
37
- * peer's PEER-STORE protocol list — and that list is written exclusively by the
38
- * IDENTIFY handshake. `@optimystic/db-p2p` gives every cadre node a
39
- * network-namespaced identify protocol id (`/optimystic/control-<partyId>/id/1.0.0`)
40
- * while the relay `ops/docker/libp2p-infra` deploys runs stock identify
41
- * (`/ipfs/id/1.0.0`), so the two never identify each other, the peer store stays
42
- * empty of protocols, and discovery never nominates the relay. The TCP/WebSocket
43
- * connection itself is fine, which is why leaving it to discovery surfaced as a
44
- * generic timeout.
45
- *
46
- * Identify is incidental to relaying, though: the reservation
47
- * (`/libp2p/circuit/relay/0.2.0/hop`), a third peer's CONNECT through the relay,
48
- * and the STOP handler on this node all use STOCK, un-namespaced protocol ids the
49
- * deployed relay already serves. Discovery's only job is to GUESS which connected
50
- * peer is a relay, and this module does not need to guess — it was handed the
51
- * relay's address. So it asks the relay for a slot directly, via the
52
- * circuit-relay transport's own reservation store.
53
- *
54
- * Status is derived from the node's LIVE multiaddrs on every read rather than
55
- * cached at drive time: when a relay restarts or the connection drops, libp2p's
56
- * listener clears its listening addrs and the circuit multiaddr disappears, so a
57
- * cached snapshot would keep claiming `reserved` for a node nothing can dial.
58
- *
59
- * And because discovery is out of reach, nothing would ever ASK again either —
60
- * so {@link driveRelayReservation} stays a single-shot primitive and
61
- * {@link superviseRelayReservation} owns the retry cadence on top of it. That
62
- * loop is what makes a lost reservation recover without a page reload.
63
- */
64
-
65
- import debug from 'debug';
66
- import { multiaddr } from '@multiformats/multiaddr';
67
- import { peerIdFromString } from '@libp2p/peer-id';
68
- import type { Libp2p, PeerId } from '@libp2p/interface';
69
- import { trailingPeerId } from './peer-record.js';
70
-
71
- const log = debug('sereus:cadre:relay-reservation');
72
-
73
- /**
74
- * Relay-reservation posture for a node that reserves through the SEARCH listen
75
- * addr (bare `/p2p-circuit`) — every cadre control node, whether its relays came
76
- * from `network.relayAddrs` or from an explicit `CadreNode.reserveRelays()` call.
77
- * - `none` — no relay addrs supplied; the node is undialable by design.
78
- * - `dialing` — a drive is in flight.
79
- * - `reserved` — the node currently holds at least one `/p2p-circuit` addr.
80
- * - `retrying` — no reservation right now, but a {@link RelayReservationSupervisor}
81
- * has the next attempt scheduled. Recoverable without a caller.
82
- * - `error` — no reservation, and NOBODY IS GOING TO TRY AGAIN: no supervisor
83
- * is running (or there is no node at all).
84
- */
85
- export type RelayReservationStatus = 'none' | 'dialing' | 'reserved' | 'retrying' | 'error';
86
-
87
- export interface RelayReservationState {
88
- status: RelayReservationStatus;
89
- /** The relay multiaddrs this node was asked to reserve through. */
90
- addrs: string[];
91
- /** LIVE `/p2p-circuit` multiaddrs, recomputed on every read. */
92
- circuitAddrs: string[];
93
- error: string | null;
94
- /**
95
- * Epoch ms of the supervisor's next scheduled tick, or `null` when a drive is
96
- * in flight, no supervisor is running, or no addrs were supplied. A UI can turn
97
- * this into "reconnecting, next try in 8s" rather than showing a bare error for
98
- * a node that is in fact recovering.
99
- */
100
- retryAtMs: number | null;
101
- }
102
-
103
- /** How long {@link driveRelayReservation} waits for a reservation to appear. */
104
- export const DEFAULT_RELAY_RESERVE_TIMEOUT_MS = 10_000;
105
- export const DEFAULT_RELAY_RESERVE_POLL_MS = 250;
106
-
107
- export interface RelayReserveOptions {
108
- timeoutMs?: number;
109
- pollMs?: number;
110
- }
111
-
112
- /**
113
- * The slice of `@libp2p/utils`' `Filter` (a cuckoo filter) that the reservation
114
- * store uses to remember relays whose reservation request failed. `remove` is
115
- * OPTIONAL on that interface, so every use here has to cope with its absence —
116
- * see {@link clearRelayFilterEntry}.
117
- */
118
- export interface RelayFilterLike {
119
- has(item: Uint8Array): boolean;
120
- remove?(item: Uint8Array): boolean;
121
- }
122
-
123
- /**
124
- * The slice of `@libp2p/circuit-relay-v2`'s `ReservationStore` this module drives.
125
- * Declared structurally because the class is internal to that package: naming only
126
- * the members we call keeps the coupling small, explicit and greppable, and lets
127
- * {@link findCircuitRelayTransport} duck-type rather than brand-match.
128
- *
129
- * `relayFilter` is optional because it is not required to DRIVE a reservation,
130
- * only to RE-drive one (see {@link clearRelayFilterEntry}); a libp2p version that
131
- * renames or drops it must degrade, not break.
132
- */
133
- export interface RelayReservationStoreLike {
134
- addRelay(peerId: PeerId, type: 'discovered' | 'configured'): Promise<unknown>;
135
- hasReservation(peerId: PeerId): boolean;
136
- relayFilter?: RelayFilterLike;
137
- }
138
-
139
- /** The slice of libp2p's circuit-relay transport that owns the reservation store. */
140
- export interface CircuitRelayTransportLike {
141
- reservationStore: RelayReservationStoreLike;
142
- }
143
-
144
- /** `components` is a real field on libp2p's node class but not on the `Libp2p` interface. */
145
- interface Libp2pComponentsLike {
146
- components?: {
147
- transportManager?: {
148
- getTransports?: () => readonly unknown[];
149
- };
150
- };
151
- }
152
-
153
- /**
154
- * The node's `/p2p-circuit` multiaddrs — the addresses a peer can dial it at
155
- * while it holds a relay reservation. Empty means "not dialable via a relay
156
- * right now", which is exactly what makes {@link resolveRelayReservationState}
157
- * live rather than a stale snapshot.
158
- */
159
- export function circuitMultiaddrs(node: Libp2p): string[] {
160
- return node
161
- .getMultiaddrs()
162
- .map((ma) => ma.toString())
163
- .filter((addr) => addr.includes('/p2p-circuit'));
164
- }
165
-
166
- /**
167
- * The running node's circuit-relay transport, or `null` when it has none.
168
- *
169
- * There is no public libp2p API for "reserve on THIS specific relay", so this
170
- * reaches through `node.components.transportManager` — libp2p's internal layout.
171
- * That coupling is deliberate and bounded: every step is optional-chained and the
172
- * whole thing returns `null` rather than throwing, and a spec pins the seam so a
173
- * libp2p upgrade that moves it fails loudly instead of silently reverting this
174
- * module to the discovery wait that never fires.
175
- */
176
- export function findCircuitRelayTransport(node: Libp2p): CircuitRelayTransportLike | null {
177
- for (const transport of nodeTransports(node)) {
178
- const store = (transport as { reservationStore?: unknown }).reservationStore;
179
- if (isReservationStore(store)) {
180
- return { reservationStore: store };
181
- }
182
- }
183
- return null;
184
- }
185
-
186
- function nodeTransports(node: Libp2p): readonly unknown[] {
187
- const manager = (node as Libp2p & Libp2pComponentsLike).components?.transportManager;
188
- if (typeof manager?.getTransports !== 'function') {
189
- log('No transportManager.getTransports() on this node — libp2p internals moved?');
190
- return [];
191
- }
192
- try {
193
- return manager.getTransports();
194
- } catch (err) {
195
- log('transportManager.getTransports() threw: %o', err);
196
- return [];
197
- }
198
- }
199
-
200
- function isReservationStore(value: unknown): value is RelayReservationStoreLike {
201
- if (typeof value !== 'object' || value === null) {
202
- return false;
203
- }
204
- const store = value as Partial<RelayReservationStoreLike>;
205
- return typeof store.addRelay === 'function' && typeof store.hasReservation === 'function';
206
- }
207
-
208
- /** Sentinel resolved by the deadline race in {@link requestOneReservation}. */
209
- const DEADLINE_PASSED = Symbol('relay-reservation-deadline-passed');
210
-
211
- /** A relay that answered its dial, paired with the address it was dialed at. */
212
- interface ConnectedRelay {
213
- addr: string;
214
- peerId: PeerId;
215
- }
216
-
217
- /**
218
- * Dial every relay in `addrs`, ask the first one that answers for a reservation
219
- * slot, then wait until a `/p2p-circuit` address appears.
220
- *
221
- * Never throws — a dial rejection, a rejected reservation, a MALFORMED address,
222
- * or a timeout all come back as an `error` string. A dial that throws does NOT
223
- * cost the rest of the list: one dead relay among two must not cost the
224
- * reservation on the live one, so every addr is dialed and the first error (in
225
- * list order) is kept. A reservation that lands wins over any earlier error
226
- * (`error: null`).
227
- *
228
- * `timeoutMs` bounds the WHOLE drive — dials, reservation requests and the wait
229
- * share one deadline.
230
- */
231
- export async function driveRelayReservation(
232
- node: Libp2p,
233
- addrs: readonly string[],
234
- opts?: RelayReserveOptions
235
- ): Promise<{ error: string | null }> {
236
- if (addrs.length === 0) {
237
- return { error: null };
238
- }
239
- const timeoutMs = opts?.timeoutMs ?? DEFAULT_RELAY_RESERVE_TIMEOUT_MS;
240
- // Clamped: a caller-supplied 0 would spin the poll loop hot until the deadline.
241
- const pollMs = Math.max(1, opts?.pollMs ?? DEFAULT_RELAY_RESERVE_POLL_MS);
242
- const deadline = Date.now() + timeoutMs;
243
-
244
- const { connected, error: dialError } = await dialRelays(node, addrs, deadline);
245
- const attempt = await requestReservation(node, connected, deadline);
246
- // NOTE: a REJECTED reservation still spends the rest of the deadline polling,
247
- // because discovery may independently land one (only the no-transport case is
248
- // `fatal`). So a misconfigured node reports its now-legible reason a full
249
- // `timeoutMs` late. If startup latency on that path ever matters, shorten the
250
- // wait once every connected relay has rejected — do not skip it outright.
251
- if (!attempt.fatal && (await waitForCircuitReservation(node, deadline, pollMs))) {
252
- return { error: null };
253
- }
254
- return {
255
- error: attempt.error ?? dialError ?? `no circuit reservation within ${timeoutMs}ms`
256
- };
257
- }
258
-
259
- /**
260
- * Dial every relay CONCURRENTLY under the drive's shared deadline. Reports the
261
- * relays that answered (in list order, for the reservation step) and the first
262
- * failure in LIST order (`null` when every dial connected).
263
- *
264
- * Concurrent and deadline-bound because this whole drive is awaited on a browser
265
- * tab's startup path: dialing serially with libp2p's own (much longer) per-dial
266
- * timeout meant N unreachable relays cost N dial timeouts before the reservation
267
- * request even began, so `timeoutMs` bounded only the tail of the operation.
268
- *
269
- * Aborting a dial at the deadline is not a lost reservation — the steps that
270
- * follow have no time left either, so the drive would report `error` regardless.
271
- */
272
- async function dialRelays(
273
- node: Libp2p,
274
- addrs: readonly string[],
275
- deadline: number
276
- ): Promise<{ connected: ConnectedRelay[]; error: string | null }> {
277
- // An explicit controller, not `AbortSignal.timeout` — the latter is not
278
- // reliably present on React Native/Hermes, which runs this same module.
279
- const controller = new AbortController();
280
- const timer = setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now()));
281
- try {
282
- const results = await Promise.allSettled(
283
- addrs.map((addr) => dialOne(node, addr, controller.signal))
284
- );
285
- return { connected: connectedRelays(addrs, results), error: firstDialError(addrs, results) };
286
- } finally {
287
- clearTimeout(timer);
288
- }
289
- }
290
-
291
- /**
292
- * One dial, with the address PARSED INSIDE the promise.
293
- *
294
- * `multiaddr()` throws SYNCHRONOUSLY on a malformed string, and a throw out of
295
- * the `map` callback escapes `Promise.allSettled` altogether — it would reject
296
- * the whole drive (breaking the never-throws contract) and discard the dials to
297
- * the other, perfectly good, addrs. `async` demotes it to a settled rejection,
298
- * which is already exactly how a dial failure is reported.
299
- */
300
- async function dialOne(
301
- node: Libp2p,
302
- addr: string,
303
- signal: AbortSignal
304
- ): Promise<{ remotePeer: PeerId }> {
305
- return node.dial(multiaddr(addr), { signal });
306
- }
307
-
308
- function connectedRelays(
309
- addrs: readonly string[],
310
- results: readonly PromiseSettledResult<{ remotePeer: PeerId }>[]
311
- ): ConnectedRelay[] {
312
- const connected: ConnectedRelay[] = [];
313
- results.forEach((result, index) => {
314
- if (result.status !== 'fulfilled') return;
315
- connected.push({ addr: addrs[index], peerId: result.value.remotePeer });
316
- });
317
- return connected;
318
- }
319
-
320
- /** Log every dial rejection; return the first one in list order. */
321
- function firstDialError(
322
- addrs: readonly string[],
323
- results: readonly PromiseSettledResult<unknown>[]
324
- ): string | null {
325
- let first: string | null = null;
326
- results.forEach((result, index) => {
327
- if (result.status !== 'rejected') return;
328
- const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
329
- log('Relay dial failed (%s): %s', addrs[index], message);
330
- first ??= message;
331
- });
332
- return first;
333
- }
334
-
335
- /**
336
- * Outcome of the explicit reservation request.
337
- *
338
- * `fatal` means no `/p2p-circuit` address can EVER appear (the node has no
339
- * circuit-relay transport at all), so the caller must report immediately rather
340
- * than spend the rest of the deadline polling for something that cannot happen.
341
- * A rejected request is NOT fatal: discovery may still land a reservation for a
342
- * relay that does happen to share this node's identify prefix.
343
- */
344
- interface ReservationAttempt {
345
- error: string | null;
346
- fatal: boolean;
347
- }
348
-
349
- /**
350
- * Ask the connected relays, in list order, for a reservation slot; stop at the
351
- * first success.
352
- *
353
- * `'discovered'`, not `'configured'`: the search listener's
354
- * `relay:created-reservation` handler ignores `configured` reservations outright,
355
- * so the `/p2p-circuit` listen address would never be published and
356
- * `getMultiaddrs()` would stay empty even though the reservation succeeded.
357
- * `'discovered'` consumes the pending-reservation id the bare `/p2p-circuit`
358
- * listener registered, which is exactly what that handler matches on.
359
- *
360
- * Sequential, not concurrent: a bare `/p2p-circuit` listener registers exactly
361
- * ONE pending reservation, so a second concurrent `addRelay` would be rejected
362
- * with `HadEnoughRelaysError` — noise, not a failure. Running discovery is not a
363
- * conflict either; `addRelay` dedupes through its own peer queue, so a relay that
364
- * genuinely is discoverable still reserves exactly once.
365
- */
366
- async function requestReservation(
367
- node: Libp2p,
368
- connected: readonly ConnectedRelay[],
369
- deadline: number
370
- ): Promise<ReservationAttempt> {
371
- if (connected.length === 0) {
372
- // Nothing answered — the dial error is the whole story.
373
- return { error: null, fatal: false };
374
- }
375
- const transport = findCircuitRelayTransport(node);
376
- if (transport === null) {
377
- return {
378
- error: 'node has no circuit-relay transport — add circuitRelayTransport() to its transports',
379
- fatal: true
380
- };
381
- }
382
- let firstError: string | null = null;
383
- for (const relay of connected) {
384
- if (transport.reservationStore.hasReservation(relay.peerId)) {
385
- return { error: null, fatal: false };
386
- }
387
- if (Date.now() >= deadline) {
388
- break;
389
- }
390
- const failure = await requestOneReservation(transport.reservationStore, relay, deadline);
391
- if (failure === null) {
392
- return { error: null, fatal: false };
393
- }
394
- firstError ??= failure;
395
- }
396
- return { error: firstError, fatal: false };
397
- }
398
-
399
- /**
400
- * One `addRelay` call, bounded by the drive's deadline. Returns `null` on
401
- * success, or the reason it failed.
402
- *
403
- * Deadline-raced because `addRelay` runs on libp2p's own (much longer) reservation
404
- * timeout and takes no signal: without the race a drive given 1.5 s would sit on a
405
- * silent relay for libp2p's timeout instead. The abandoned promise stays handled —
406
- * `Promise.race` attaches its own handlers — so a later rejection is not unhandled.
407
- */
408
- async function requestOneReservation(
409
- store: RelayReservationStoreLike,
410
- relay: ConnectedRelay,
411
- deadline: number
412
- ): Promise<string | null> {
413
- let timer: ReturnType<typeof setTimeout> | undefined;
414
- const expired = new Promise<typeof DEADLINE_PASSED>((resolve) => {
415
- timer = setTimeout(() => resolve(DEADLINE_PASSED), Math.max(0, deadline - Date.now()));
416
- });
417
- try {
418
- const outcome = await Promise.race([store.addRelay(relay.peerId, 'discovered'), expired]);
419
- if (outcome === DEADLINE_PASSED) {
420
- log('Relay reservation request (%s) still pending at the deadline', relay.addr);
421
- return `relay reservation request to ${relay.addr} did not complete before the deadline`;
422
- }
423
- return null;
424
- } catch (err) {
425
- const message = describeReservationFailure(err, relay.addr);
426
- log('Relay reservation failed (%s): %s', relay.addr, message);
427
- return message;
428
- } finally {
429
- clearTimeout(timer);
430
- }
431
- }
432
-
433
- /**
434
- * Turn libp2p's reservation rejection into a reason that names the actual cause.
435
- *
436
- * `HadEnoughRelaysError` needs translating rather than passing through: its own
437
- * message ("we do not need any more relays") says the opposite of what happened.
438
- * A `'discovered'` reservation is refused when the node holds NO pending
439
- * reservation id, and a node holds one only while it listens on the bare
440
- * `/p2p-circuit` search address — so the real cause is a missing listen address.
441
- */
442
- function describeReservationFailure(err: unknown, addr: string): string {
443
- // NOTE: libp2p adds a peer to the reservation store's `relayFilter` when a
444
- // request fails with `DialError` or `UnsupportedProtocolError`, and the failure
445
- // path does not reset that filter (it only resets when a reservation is actually
446
- // removed). So a SECOND drive against the same relay in the same process reports
447
- // `ListenError: The relay was previously invalid` even if the relay has since
448
- // recovered. `RelayReservationLoop` clears the entry before every attempt (see
449
- // {@link clearRelayFilterEntry}); a caller that re-drives WITHOUT the supervisor
450
- // still has to deal with the filter itself.
451
- const name = err instanceof Error ? err.name : '';
452
- const message = err instanceof Error ? err.message : String(err);
453
- switch (name) {
454
- case 'HadEnoughRelaysError':
455
- return `relay reservation on ${addr} was not requested: this node holds no pending circuit reservation — it is not listening on the bare /p2p-circuit address`;
456
- case 'UnsupportedProtocolError':
457
- return `peer at ${addr} does not speak the circuit-relay hop protocol — it is not a relay`;
458
- default:
459
- return `relay reservation on ${addr} failed: ${message}`;
460
- }
461
- }
462
-
463
- /**
464
- * Poll until the node advertises a `/p2p-circuit` address, or the deadline passes.
465
- *
466
- * Still a poll even though the reservation is now requested explicitly: `addRelay`
467
- * resolving means the RELAY accepted, while the listen address is published a tick
468
- * later by the listener's `relay:created-reservation` handler, and a reservation
469
- * that discovery lands independently has no return value to await at all.
470
- *
471
- * NOTE: polls rather than subscribing to libp2p's `self:peer:update` — it keeps
472
- * this free of libp2p event-name coupling. If 250 ms of reservation latency ever
473
- * matters, switch to the event and keep the poll as a fallback.
474
- */
475
- async function waitForCircuitReservation(
476
- node: Libp2p,
477
- deadline: number,
478
- pollMs: number
479
- ): Promise<boolean> {
480
- for (;;) {
481
- if (circuitMultiaddrs(node).length > 0) {
482
- return true;
483
- }
484
- if (Date.now() >= deadline) {
485
- return false;
486
- }
487
- await delay(Math.min(pollMs, Math.max(0, deadline - Date.now())));
488
- }
489
- }
490
-
491
- function delay(ms: number): Promise<void> {
492
- return new Promise((resolve) => setTimeout(resolve, ms));
493
- }
494
-
495
- /** Gap between liveness checks while a reservation is held. */
496
- export const DEFAULT_RELAY_CHECK_MS = 5_000;
497
- /** Backoff before the first re-drive after a failed attempt. */
498
- export const DEFAULT_RELAY_MIN_BACKOFF_MS = 2_000;
499
- /** Ceiling the backoff doubles up to. */
500
- export const DEFAULT_RELAY_MAX_BACKOFF_MS = 60_000;
501
-
502
- export interface RelayReservationSupervisorOptions extends RelayReserveOptions {
503
- /** Gap between liveness checks while a reservation is held. Default 5_000. */
504
- checkMs?: number;
505
- /** Backoff before the first re-drive after a failure. Default 2_000. */
506
- minBackoffMs?: number;
507
- /** Backoff ceiling. Default 60_000. */
508
- maxBackoffMs?: number;
509
- }
510
-
511
- /**
512
- * A running retry loop for one node + relay list. Obtained from
513
- * {@link superviseRelayReservation}; its only imperative is {@link stop}.
514
- */
515
- export interface RelayReservationSupervisor {
516
- /**
517
- * Resolves once the FIRST attempt has settled — so a caller can await startup
518
- * exactly as it awaited a single {@link driveRelayReservation}, and the retries
519
- * carry on in the background afterwards. Also resolves on {@link stop}, so an
520
- * awaiting caller is never left hanging.
521
- */
522
- readonly firstAttempt: Promise<void>;
523
- /** True while a drive is in flight. */
524
- readonly driving: boolean;
525
- /** Epoch ms of the next scheduled tick; `null` while a drive is running. */
526
- readonly retryAtMs: number | null;
527
- /** Reason the last drive produced no reservation; `null` once one is held. */
528
- readonly lastError: string | null;
529
- /**
530
- * Idempotent. Clears the timer so no further drive is scheduled. A drive that
531
- * is ALREADY in flight is not aborted — {@link driveRelayReservation} takes no
532
- * signal — but its result is discarded and nothing follows it.
533
- */
534
- stop(): void;
535
- }
536
-
537
- /**
538
- * Keep asking for a relay reservation until one is held, then keep watching that
539
- * it still is — the thing that makes a lost reservation recover on its own.
540
- *
541
- * Needed because nothing else re-drives: libp2p re-fills a search listener's
542
- * freed slot from relay DISCOVERY, and a cadre node's namespaced identify puts
543
- * discovery permanently out of reach (see the module header). Without this loop a
544
- * relay restart left a browser tab undialable until the user reloaded the page.
545
- *
546
- * Per tick:
547
- * - a reservation is held → reset the backoff, clear the error, re-check in
548
- * `checkMs`. No drive: a healthy node must not re-request every few seconds.
549
- * - otherwise → un-poison the reservation store's relay filter (see
550
- * {@link clearRelayFilterEntry}) and run ONE {@link driveRelayReservation}.
551
- * If that lands a reservation, rejoin the healthy path above; if not, sleep
552
- * the current backoff and double it up to `maxBackoffMs`.
553
- *
554
- * Starts immediately; the first tick runs before this returns.
555
- */
556
- export function superviseRelayReservation(
557
- node: Libp2p,
558
- addrs: readonly string[],
559
- opts?: RelayReservationSupervisorOptions
560
- ): RelayReservationSupervisor {
561
- return new RelayReservationLoop(node, [...addrs], opts ?? {});
562
- }
563
-
564
- /**
565
- * Forget that `addr`'s peer ever failed a reservation request, so the next
566
- * attempt is actually made rather than refused out of hand.
567
- *
568
- * `ReservationStore` records a peer in `relayFilter` when its reservation request
569
- * fails with `DialError` or `UnsupportedProtocolError`, and NOTHING on that path
570
- * ever clears it: the reset lives in `#checkReservationCount`, which only runs
571
- * when a reservation is genuinely removed, and `#removeReservation` early-returns
572
- * when there was never one. So a relay that was briefly not-a-relay (or died
573
- * between our dial and the hop request) stays permanently rejected with
574
- * `The relay was previously invalid` — measured, not inferred.
575
- *
576
- * Fails soft in every direction: no `relayFilter`, no `remove`, an addr with no
577
- * peer id, a malformed addr — all mean "no un-poisoning", never a throw. The
578
- * retry loop still recovers the common case without this, because a relay that is
579
- * simply DOWN fails our own dial first and never reaches the filter.
580
- *
581
- * Returns whether an entry was actually removed (for specs; callers ignore it).
582
- */
583
- export function clearRelayFilterEntry(store: RelayReservationStoreLike, addr: string): boolean {
584
- const filter = store.relayFilter;
585
- if (typeof filter?.remove !== 'function') {
586
- log('Reservation store has no removable relayFilter — cannot un-poison %s', addr);
587
- return false;
588
- }
589
- try {
590
- // The peer id is in the multiaddr, so this costs no dial and can run before one.
591
- const peerId = trailingPeerId(multiaddr(addr));
592
- if (peerId === null) {
593
- // NOTE: an addr with no `/p2p/` component can still be dialed and reserved
594
- // through (the peer id comes off the connection), but it can never be
595
- // un-poisoned — so for THAT addr a poisoned filter is permanent again. No
596
- // caller supplies such an addr today; if one ever does, resolve the peer id
597
- // from the live connection instead of the multiaddr.
598
- return false;
599
- }
600
- return filter.remove(peerIdFromString(peerId).toMultihash().bytes);
601
- } catch (err) {
602
- log('Could not clear the relayFilter entry for %s: %o', addr, err);
603
- return false;
604
- }
605
- }
606
-
607
- /** The self-rescheduling loop behind {@link superviseRelayReservation}. */
608
- class RelayReservationLoop implements RelayReservationSupervisor {
609
- readonly firstAttempt: Promise<void>;
610
-
611
- private readonly checkMs: number;
612
- private readonly minBackoffMs: number;
613
- private readonly maxBackoffMs: number;
614
- private backoffMs: number;
615
- private timer: ReturnType<typeof setTimeout> | null = null;
616
- private stopped = false;
617
- private inFlight = false;
618
- private nextTickAtMs: number | null = null;
619
- private failure: string | null = null;
620
- private settleFirstAttempt: () => void = () => {};
621
- private firstAttemptSettled = false;
622
-
623
- constructor(
624
- private readonly node: Libp2p,
625
- private readonly addrs: readonly string[],
626
- private readonly opts: RelayReservationSupervisorOptions
627
- ) {
628
- // Clamped away from 0: a caller-supplied 0 would spin the loop hot.
629
- this.checkMs = Math.max(1, opts.checkMs ?? DEFAULT_RELAY_CHECK_MS);
630
- this.minBackoffMs = Math.max(1, opts.minBackoffMs ?? DEFAULT_RELAY_MIN_BACKOFF_MS);
631
- this.maxBackoffMs = Math.max(
632
- this.minBackoffMs,
633
- opts.maxBackoffMs ?? DEFAULT_RELAY_MAX_BACKOFF_MS
634
- );
635
- this.backoffMs = this.minBackoffMs;
636
- this.firstAttempt = new Promise((resolve) => {
637
- this.settleFirstAttempt = resolve;
638
- });
639
- void this.tick();
640
- }
641
-
642
- get driving(): boolean {
643
- return this.inFlight;
644
- }
645
-
646
- get retryAtMs(): number | null {
647
- return this.nextTickAtMs;
648
- }
649
-
650
- get lastError(): string | null {
651
- return this.failure;
652
- }
653
-
654
- stop(): void {
655
- if (this.stopped) {
656
- return;
657
- }
658
- this.stopped = true;
659
- if (this.timer !== null) {
660
- clearTimeout(this.timer);
661
- this.timer = null;
662
- }
663
- this.nextTickAtMs = null;
664
- this.settleFirst();
665
- }
666
-
667
- private async tick(): Promise<void> {
668
- if (this.stopped) {
669
- return;
670
- }
671
- this.nextTickAtMs = null;
672
- if (this.reservationHeld()) {
673
- this.onReservationHeld();
674
- return;
675
- }
676
- await this.driveOnce();
677
- if (this.stopped) {
678
- return;
679
- }
680
- this.settleFirst();
681
- // A drive that LANDED a reservation resumes the healthy cadence immediately.
682
- // Backing off here instead would leave the first liveness check after a long
683
- // outage a fully grown backoff away (up to `maxBackoffMs`), so a reservation
684
- // lost again right after recovery would go unnoticed for that whole window.
685
- if (this.reservationHeld()) {
686
- this.onReservationHeld();
687
- return;
688
- }
689
- this.scheduleBackoff();
690
- }
691
-
692
- private reservationHeld(): boolean {
693
- return circuitMultiaddrs(this.node).length > 0;
694
- }
695
-
696
- /** Healthy tick: nothing to request, so only reset and re-check later. */
697
- private onReservationHeld(): void {
698
- this.backoffMs = this.minBackoffMs;
699
- this.failure = null;
700
- this.settleFirst();
701
- this.schedule(this.checkMs);
702
- }
703
-
704
- private async driveOnce(): Promise<void> {
705
- this.inFlight = true;
706
- try {
707
- this.unpoisonRelayFilter();
708
- const { error } = await driveRelayReservation(this.node, this.addrs, this.opts);
709
- if (!this.stopped) {
710
- this.failure = error;
711
- }
712
- } catch (err) {
713
- // `driveRelayReservation` is fail-soft by contract, so reaching here means
714
- // that contract broke. The loop must survive it anyway: an escaping
715
- // rejection would leave `firstAttempt` pending FOREVER (hanging every
716
- // caller that awaits a reservation at startup) and schedule no further
717
- // attempt, which is the failure this whole supervisor exists to prevent.
718
- const message = err instanceof Error ? err.message : String(err);
719
- log('Relay reservation drive threw, which it should not: %s', message);
720
- if (!this.stopped) {
721
- this.failure = message;
722
- }
723
- } finally {
724
- this.inFlight = false;
725
- }
726
- }
727
-
728
- private scheduleBackoff(): void {
729
- const wait = this.backoffMs;
730
- this.backoffMs = Math.min(this.backoffMs * 2, this.maxBackoffMs);
731
- this.schedule(wait);
732
- }
733
-
734
- private unpoisonRelayFilter(): void {
735
- const transport = findCircuitRelayTransport(this.node);
736
- if (transport === null) {
737
- return;
738
- }
739
- for (const addr of this.addrs) {
740
- clearRelayFilterEntry(transport.reservationStore, addr);
741
- }
742
- }
743
-
744
- private schedule(ms: number): void {
745
- if (this.stopped) {
746
- return;
747
- }
748
- this.nextTickAtMs = Date.now() + ms;
749
- this.timer = setTimeout(() => {
750
- this.timer = null;
751
- void this.tick();
752
- }, ms);
753
- // Node keeps the process alive while a timer is pending, so an un-unref'd
754
- // supervisor would hang every CLI run and vitest worker that ever reserved.
755
- // Cast + optional-chain: browsers and React Native have no `unref`.
756
- (this.timer as { unref?: () => void }).unref?.();
757
- }
758
-
759
- private settleFirst(): void {
760
- if (this.firstAttemptSettled) {
761
- return;
762
- }
763
- this.firstAttemptSettled = true;
764
- this.settleFirstAttempt();
765
- }
766
- }
767
-
768
- /**
769
- * Derive the reservation posture, reading the node's circuit addrs LIVE. Pure
770
- * apart from that read, so the precedence is testable on its own:
771
- *
772
- * | condition | status |
773
- * | ------------------------ | ----------------------------------------------- |
774
- * | no addrs supplied | `none` |
775
- * | live circuit addrs held | `reserved` (`error: null`) |
776
- * | a drive is in flight | `dialing` |
777
- * | a retry is scheduled | `retrying` |
778
- * | otherwise | `error` |
779
- *
780
- * `reserved` is checked BEFORE `error` on purpose: the circuit addrs are read
781
- * live, so a reservation that lands by ANY route supersedes a stale error string
782
- * without a second drive.
783
- *
784
- * `retrying` sits between the two so that `error` carries a sharper meaning:
785
- * NOBODY IS GOING TO TRY AGAIN. A lost reservation does come back on its own now,
786
- * as long as a {@link RelayReservationSupervisor} is running — that loop is the
787
- * only thing that re-drives, since libp2p re-fills a search listener's freed slot
788
- * from relay DISCOVERY, which a cadre node's namespaced identify puts permanently
789
- * out of reach (see the module header). So `error` means the supervisor was
790
- * stopped, was never started, or there is no node at all.
791
- *
792
- * `retryAtMs` is the supervisor's next scheduled tick (`null` while a drive runs
793
- * or when there is no supervisor); it both selects `retrying` and is passed
794
- * through so a UI can count down to the next attempt.
795
- */
796
- export function resolveRelayReservationState(
797
- node: Libp2p | null,
798
- addrs: readonly string[],
799
- lastError: string | null,
800
- driving: boolean,
801
- retryAtMs: number | null
802
- ): RelayReservationState {
803
- if (addrs.length === 0) {
804
- return { status: 'none', addrs: [], circuitAddrs: [], error: null, retryAtMs: null };
805
- }
806
- const supplied = [...addrs];
807
- const circuitAddrs = node ? circuitMultiaddrs(node) : [];
808
- if (circuitAddrs.length > 0) {
809
- return { status: 'reserved', addrs: supplied, circuitAddrs, error: null, retryAtMs };
810
- }
811
- if (driving) {
812
- return {
813
- status: 'dialing',
814
- addrs: supplied,
815
- circuitAddrs: [],
816
- error: lastError,
817
- retryAtMs: null
818
- };
819
- }
820
- // `driveRelayReservation` always reports a reason when it finishes without a
821
- // reservation, so `lastError` is normally set below. The fallback only covers
822
- // addrs recorded without a completed drive.
823
- const error = lastError ?? 'no circuit reservation held';
824
- if (retryAtMs !== null) {
825
- return { status: 'retrying', addrs: supplied, circuitAddrs: [], error, retryAtMs };
826
- }
827
- return { status: 'error', addrs: supplied, circuitAddrs: [], error, retryAtMs: null };
828
- }
1
+ /**
2
+ * Relay reservation for nodes that reserve through the SEARCH listen addr — the
3
+ * dial, the explicit reservation request, the retry loop that keeps it, and the
4
+ * (live) status derivation.
5
+ *
6
+ * `@libp2p/circuit-relay-v2`'s listener branches on the SHAPE of the listen
7
+ * address:
8
+ *
9
+ * | listen addr | libp2p behaviour | unreachable relay |
10
+ * | ------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------- |
11
+ * | `<dial addr>/p2p/<relayPeerId>/p2p-circuit` | CONFIGURED reservation: dial that exact relay and reserve, or fail | `listen()` throws |
12
+ * | bare `/p2p-circuit` | SEARCH mode: register a pending reservation, to be filled by relay discovery | nothing throws, no reserve |
13
+ *
14
+ * A search listener needs someone to (a) dial the relay and (b) fill the pending
15
+ * reservation it created, then report whether one actually landed. That is this
16
+ * module. It is **fail-soft by construction**: nothing here throws.
17
+ *
18
+ * EVERY cadre CONTROL node now takes that route — `network.relayAddrs` resolves
19
+ * to the bare search entry (`relay-addrs.ts` → `resolveListenAddrs`) and
20
+ * `CadreNode.start()` drives this module once the control database is up. The
21
+ * configured shape would dial the relay from inside `libp2p.start()`, putting a
22
+ * sibling in this node's cohort before its own control database existed, and a
23
+ * sibling that has not yet replicated this node's `CadrePeer` row correctly
24
+ * refuses its control-DB streams — which killed `start()` outright.
25
+ *
26
+ * So the two shapes are no longer alternative ROUTES, only alternative failure
27
+ * postures on top of the same drive: a node that names `network.relayAddrs` gets
28
+ * a `RelayReservationFailedError` out of `start()` when the first attempt lands
29
+ * nothing (fail-fast, the operator asked for this relay), while a node that calls
30
+ * `CadreNode.reserveRelays()` itself gets a non-`reserved` status and stays up
31
+ * (fail-soft, the browser-tab posture). `network.requireRelay: false` gives a
32
+ * CONFIGURED relay the fail-soft posture too, for a node that must boot with no
33
+ * network at all — same drive, same supervisor, only the throw is dropped.
34
+ * Configuring both is now redundant, not fatal.
35
+ *
36
+ * STRAND nodes take the same route, ONE supervisor PER RELAY
37
+ * (`strand-instance-manager.ts` → `buildStrandRuntime`): each configured relay
38
+ * gets its own bare `/p2p-circuit` listener (`strand-network-config.ts`) and its
39
+ * own {@link superviseRelayReservation} over just that relay's addr, fail-soft
40
+ * like the browser tab. The configured shape they used to take
41
+ * (`<relay>/p2p-circuit`, reserved from inside `libp2p.start()`) is gone from
42
+ * cadre-built nodes entirely, because it loses its address and never recovers in
43
+ * three situations — the relay restarts, either side hangs up the relay
44
+ * connection, and libp2p's OWN reservation refresh (which removes the reservation
45
+ * before re-creating it, and a configured listener republishes only from inside
46
+ * its own `listen()`). The last one needs no network event at all: at the relay
47
+ * default TTL of two hours every configured-shape node went undialable ~1 h 55 min
48
+ * after reserving. A search listener republishes on `relay:created-reservation`
49
+ * for its own pending id, which the refresh re-queues, and the supervisor covers
50
+ * the other two.
51
+ *
52
+ * Because a strand node runs one supervisor per relay, "is the reservation held"
53
+ * is asked PER RELAY ({@link circuitMultiaddrsVia}): relay X's supervisor must not
54
+ * be satisfied by relay Y's circuit addr, or it would never re-drive X.
55
+ *
56
+ * ⚠️ WHY THE RESERVATION IS REQUESTED EXPLICITLY RATHER THAN LEFT TO DISCOVERY.
57
+ * libp2p fills a search listener's pending reservation from `RelayDiscovery`,
58
+ * which nominates a peer only once it can see the relay-hop protocol id in that
59
+ * peer's PEER-STORE protocol list — and that list is written exclusively by the
60
+ * IDENTIFY handshake. `@optimystic/db-p2p` gives every cadre node a
61
+ * network-namespaced identify protocol id (`/optimystic/control-<partyId>/id/1.0.0`)
62
+ * while the relay `ops/docker/libp2p-infra` deploys runs stock identify
63
+ * (`/ipfs/id/1.0.0`), so the two never identify each other, the peer store stays
64
+ * empty of protocols, and discovery never nominates the relay. The TCP/WebSocket
65
+ * connection itself is fine, which is why leaving it to discovery surfaced as a
66
+ * generic timeout.
67
+ *
68
+ * Identify is incidental to relaying, though: the reservation
69
+ * (`/libp2p/circuit/relay/0.2.0/hop`), a third peer's CONNECT through the relay,
70
+ * and the STOP handler on this node all use STOCK, un-namespaced protocol ids the
71
+ * deployed relay already serves. Discovery's only job is to GUESS which connected
72
+ * peer is a relay, and this module does not need to guess — it was handed the
73
+ * relay's address. So it asks the relay for a slot directly, via the
74
+ * circuit-relay transport's own reservation store.
75
+ *
76
+ * Status is derived from the node's LIVE multiaddrs on every read rather than
77
+ * cached at drive time: when a relay restarts or the connection drops, libp2p's
78
+ * listener clears its listening addrs and the circuit multiaddr disappears, so a
79
+ * cached snapshot would keep claiming `reserved` for a node nothing can dial.
80
+ *
81
+ * And because discovery is out of reach, nothing would ever ASK again either —
82
+ * so {@link driveRelayReservation} stays a single-shot primitive and
83
+ * {@link superviseRelayReservation} owns the retry cadence on top of it. That
84
+ * loop is what makes a lost reservation recover without a page reload.
85
+ *
86
+ * One measured refinement to "out of reach": libp2p 3.1.3's `connection.newStream`
87
+ * records every protocol OUR OWN outbound stream negotiated into the peer store, so
88
+ * the explicit reservation request writes the relay-hop protocol id against the
89
+ * relay itself. From then on, discovery CAN refill a freed slot on its own — but
90
+ * only when the relay answers at that moment (a hangup while the relay stays up;
91
+ * measured at ~25 ms). A relay that is down when the slot frees fails that one
92
+ * discovery attempt, which poisons the store's `relayFilter` against it, and
93
+ * nothing in libp2p tries again: a relay RESTART still needs the supervisor, which
94
+ * un-poisons the filter before every attempt ({@link clearRelayFilterEntry}). Two
95
+ * mechanisms in production, one guarantee; the specs disable discovery's half
96
+ * (`forgetRelayProtocol` in `relay-reservation.spec.ts`) so they prove the
97
+ * supervisor's.
98
+ */
99
+
100
+ import debug from 'debug';
101
+ import { multiaddr } from '@multiformats/multiaddr';
102
+ import { peerIdFromString } from '@libp2p/peer-id';
103
+ import type { Libp2p, PeerId } from '@libp2p/interface';
104
+ import { trailingPeerId } from './peer-record.js';
105
+
106
+ const log = debug('sereus:cadre:relay-reservation');
107
+
108
+ /**
109
+ * Relay-reservation posture for a node that reserves through the SEARCH listen
110
+ * addr (bare `/p2p-circuit`) — every cadre control node, whether its relays came
111
+ * from `network.relayAddrs` or from an explicit `CadreNode.reserveRelays()` call.
112
+ * - `none` — no relay addrs supplied; the node is undialable by design.
113
+ * - `dialing` — a drive is in flight.
114
+ * - `reserved` — the node currently holds at least one `/p2p-circuit` addr.
115
+ * - `retrying` — no reservation right now, but a {@link RelayReservationSupervisor}
116
+ * has the next attempt scheduled. Recoverable without a caller.
117
+ * - `error` — no reservation, and NOBODY IS GOING TO TRY AGAIN: no supervisor
118
+ * is running (or there is no node at all).
119
+ */
120
+ export type RelayReservationStatus = 'none' | 'dialing' | 'reserved' | 'retrying' | 'error';
121
+
122
+ export interface RelayReservationState {
123
+ status: RelayReservationStatus;
124
+ /** The relay multiaddrs this node was asked to reserve through. */
125
+ addrs: string[];
126
+ /** LIVE `/p2p-circuit` multiaddrs, recomputed on every read. */
127
+ circuitAddrs: string[];
128
+ error: string | null;
129
+ /**
130
+ * Epoch ms of the supervisor's next scheduled tick, or `null` when a drive is
131
+ * in flight, no supervisor is running, or no addrs were supplied. A UI can turn
132
+ * this into "reconnecting, next try in 8s" rather than showing a bare error for
133
+ * a node that is in fact recovering.
134
+ */
135
+ retryAtMs: number | null;
136
+ }
137
+
138
+ /** How long {@link driveRelayReservation} waits for a reservation to appear. */
139
+ export const DEFAULT_RELAY_RESERVE_TIMEOUT_MS = 10_000;
140
+ export const DEFAULT_RELAY_RESERVE_POLL_MS = 250;
141
+
142
+ export interface RelayReserveOptions {
143
+ timeoutMs?: number;
144
+ pollMs?: number;
145
+ }
146
+
147
+ /**
148
+ * The slice of `@libp2p/utils`' `Filter` (a cuckoo filter) that the reservation
149
+ * store uses to remember relays whose reservation request failed. `remove` is
150
+ * OPTIONAL on that interface, so every use here has to cope with its absence —
151
+ * see {@link clearRelayFilterEntry}.
152
+ */
153
+ export interface RelayFilterLike {
154
+ has(item: Uint8Array): boolean;
155
+ remove?(item: Uint8Array): boolean;
156
+ }
157
+
158
+ /**
159
+ * The slice of `@libp2p/circuit-relay-v2`'s `ReservationStore` this module drives.
160
+ * Declared structurally because the class is internal to that package: naming only
161
+ * the members we call keeps the coupling small, explicit and greppable, and lets
162
+ * {@link findCircuitRelayTransport} duck-type rather than brand-match.
163
+ *
164
+ * `relayFilter` is optional because it is not required to DRIVE a reservation,
165
+ * only to RE-drive one (see {@link clearRelayFilterEntry}); a libp2p version that
166
+ * renames or drops it must degrade, not break.
167
+ */
168
+ export interface RelayReservationStoreLike {
169
+ addRelay(peerId: PeerId, type: 'discovered' | 'configured'): Promise<unknown>;
170
+ hasReservation(peerId: PeerId): boolean;
171
+ relayFilter?: RelayFilterLike;
172
+ }
173
+
174
+ /** The slice of libp2p's circuit-relay transport that owns the reservation store. */
175
+ export interface CircuitRelayTransportLike {
176
+ reservationStore: RelayReservationStoreLike;
177
+ }
178
+
179
+ /** `components` is a real field on libp2p's node class but not on the `Libp2p` interface. */
180
+ interface Libp2pComponentsLike {
181
+ components?: {
182
+ transportManager?: {
183
+ getTransports?: () => readonly unknown[];
184
+ };
185
+ };
186
+ }
187
+
188
+ /**
189
+ * The node's `/p2p-circuit` multiaddrs — the addresses a peer can dial it at
190
+ * while it holds a relay reservation. Empty means "not dialable via a relay
191
+ * right now", which is exactly what makes {@link resolveRelayReservationState}
192
+ * live rather than a stale snapshot.
193
+ */
194
+ export function circuitMultiaddrs(node: Libp2p): string[] {
195
+ return node
196
+ .getMultiaddrs()
197
+ .map((ma) => ma.toString())
198
+ .filter((addr) => addr.includes('/p2p-circuit'));
199
+ }
200
+
201
+ /**
202
+ * The node's `/p2p-circuit` multiaddrs THROUGH the relays `addrs` name — the ones
203
+ * whose `/p2p/<relayPeerId>/p2p-circuit` component names one of them. This is the
204
+ * "held" question a supervisor over a SUBSET of the node's relays has to ask: a
205
+ * strand node runs one supervisor per relay, and relay X's supervisor must not be
206
+ * satisfied by relay Y's addr, or a lost X is never re-driven.
207
+ *
208
+ * Falls back to EVERY circuit addr when any entry names no relay peer id (no
209
+ * `/p2p/` component, or unparsable): such an addr can still be dialed and reserved
210
+ * through, but the addr it earns cannot be attributed to it, so "any circuit addr"
211
+ * is the only answer that does not mark a live reservation as lost. The control
212
+ * node passes its whole relay list, so its answer is the same either way.
213
+ */
214
+ export function circuitMultiaddrsVia(node: Libp2p, addrs: readonly string[]): string[] {
215
+ const held = circuitMultiaddrs(node);
216
+ const relayIds = addrs.map(relayPeerIdOf);
217
+ if (relayIds.some((id) => id === null)) {
218
+ return held;
219
+ }
220
+ return held.filter((circuit) => relayIds.includes(relayPeerIdOf(circuit)));
221
+ }
222
+
223
+ /**
224
+ * The NORMALIZED peer id of the relay `addr` names: the `/p2p/` component just
225
+ * before `/p2p-circuit` when there is one, else the trailing `/p2p/` component
226
+ * (a relay DIAL addr). `null` when there is none, or `addr` does not parse.
227
+ *
228
+ * Normalized through `peerIdFromString(…).toString()` so a relay written in a
229
+ * different encoding than libp2p publishes (a CID form, say) still matches.
230
+ */
231
+ function relayPeerIdOf(addr: string): string | null {
232
+ try {
233
+ const components = multiaddr(addr).getComponents();
234
+ const circuitIdx = components.findIndex((c) => c.name === 'p2p-circuit');
235
+ const relayScope = circuitIdx >= 0 ? components.slice(0, circuitIdx) : components;
236
+ const raw = trailingPeerId(multiaddr(relayScope));
237
+ return raw === null ? null : peerIdFromString(raw).toString();
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * The running node's circuit-relay transport, or `null` when it has none.
245
+ *
246
+ * There is no public libp2p API for "reserve on THIS specific relay", so this
247
+ * reaches through `node.components.transportManager` — libp2p's internal layout.
248
+ * That coupling is deliberate and bounded: every step is optional-chained and the
249
+ * whole thing returns `null` rather than throwing, and a spec pins the seam so a
250
+ * libp2p upgrade that moves it fails loudly instead of silently reverting this
251
+ * module to the discovery wait that never fires.
252
+ */
253
+ export function findCircuitRelayTransport(node: Libp2p): CircuitRelayTransportLike | null {
254
+ for (const transport of nodeTransports(node)) {
255
+ const store = (transport as { reservationStore?: unknown }).reservationStore;
256
+ if (isReservationStore(store)) {
257
+ return { reservationStore: store };
258
+ }
259
+ }
260
+ return null;
261
+ }
262
+
263
+ function nodeTransports(node: Libp2p): readonly unknown[] {
264
+ const manager = (node as Libp2p & Libp2pComponentsLike).components?.transportManager;
265
+ if (typeof manager?.getTransports !== 'function') {
266
+ log('No transportManager.getTransports() on this node — libp2p internals moved?');
267
+ return [];
268
+ }
269
+ try {
270
+ return manager.getTransports();
271
+ } catch (err) {
272
+ log('transportManager.getTransports() threw: %o', err);
273
+ return [];
274
+ }
275
+ }
276
+
277
+ function isReservationStore(value: unknown): value is RelayReservationStoreLike {
278
+ if (typeof value !== 'object' || value === null) {
279
+ return false;
280
+ }
281
+ const store = value as Partial<RelayReservationStoreLike>;
282
+ return typeof store.addRelay === 'function' && typeof store.hasReservation === 'function';
283
+ }
284
+
285
+ /** Sentinel resolved by the deadline race in {@link requestOneReservation}. */
286
+ const DEADLINE_PASSED = Symbol('relay-reservation-deadline-passed');
287
+
288
+ /** A relay that answered its dial, paired with the address it was dialed at. */
289
+ interface ConnectedRelay {
290
+ addr: string;
291
+ peerId: PeerId;
292
+ }
293
+
294
+ /**
295
+ * Dial every relay in `addrs`, ask the first one that answers for a reservation
296
+ * slot, then wait until a `/p2p-circuit` address appears.
297
+ *
298
+ * Never throws — a dial rejection, a rejected reservation, a MALFORMED address,
299
+ * or a timeout all come back as an `error` string. A dial that throws does NOT
300
+ * cost the rest of the list: one dead relay among two must not cost the
301
+ * reservation on the live one, so every addr is dialed and the first error (in
302
+ * list order) is kept. A reservation that lands wins over any earlier error
303
+ * (`error: null`).
304
+ *
305
+ * `timeoutMs` bounds the WHOLE drive — dials, reservation requests and the wait
306
+ * share one deadline.
307
+ */
308
+ export async function driveRelayReservation(
309
+ node: Libp2p,
310
+ addrs: readonly string[],
311
+ opts?: RelayReserveOptions
312
+ ): Promise<{ error: string | null }> {
313
+ if (addrs.length === 0) {
314
+ return { error: null };
315
+ }
316
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_RELAY_RESERVE_TIMEOUT_MS;
317
+ // Clamped: a caller-supplied 0 would spin the poll loop hot until the deadline.
318
+ const pollMs = Math.max(1, opts?.pollMs ?? DEFAULT_RELAY_RESERVE_POLL_MS);
319
+ const deadline = Date.now() + timeoutMs;
320
+
321
+ const { connected, error: dialError } = await dialRelays(node, addrs, deadline);
322
+ const attempt = await requestReservation(node, connected, deadline);
323
+ // NOTE: a REJECTED reservation still spends the rest of the deadline polling,
324
+ // because discovery may independently land one (only the no-transport case is
325
+ // `fatal`). So a misconfigured node reports its now-legible reason a full
326
+ // `timeoutMs` late. If startup latency on that path ever matters, shorten the
327
+ // wait once every connected relay has rejected — do not skip it outright.
328
+ if (!attempt.fatal && (await waitForCircuitReservation(node, addrs, deadline, pollMs))) {
329
+ return { error: null };
330
+ }
331
+ return {
332
+ error: attempt.error ?? dialError ?? `no circuit reservation within ${timeoutMs}ms`
333
+ };
334
+ }
335
+
336
+ /**
337
+ * Dial every relay CONCURRENTLY under the drive's shared deadline. Reports the
338
+ * relays that answered (in list order, for the reservation step) and the first
339
+ * failure in LIST order (`null` when every dial connected).
340
+ *
341
+ * Concurrent and deadline-bound because this whole drive is awaited on a browser
342
+ * tab's startup path: dialing serially with libp2p's own (much longer) per-dial
343
+ * timeout meant N unreachable relays cost N dial timeouts before the reservation
344
+ * request even began, so `timeoutMs` bounded only the tail of the operation.
345
+ *
346
+ * Aborting a dial at the deadline is not a lost reservation — the steps that
347
+ * follow have no time left either, so the drive would report `error` regardless.
348
+ */
349
+ async function dialRelays(
350
+ node: Libp2p,
351
+ addrs: readonly string[],
352
+ deadline: number
353
+ ): Promise<{ connected: ConnectedRelay[]; error: string | null }> {
354
+ // An explicit controller, not `AbortSignal.timeout` — the latter is not
355
+ // reliably present on React Native/Hermes, which runs this same module.
356
+ const controller = new AbortController();
357
+ const timer = setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now()));
358
+ try {
359
+ const results = await Promise.allSettled(
360
+ addrs.map((addr) => dialOne(node, addr, controller.signal))
361
+ );
362
+ return { connected: connectedRelays(addrs, results), error: firstDialError(addrs, results) };
363
+ } finally {
364
+ clearTimeout(timer);
365
+ }
366
+ }
367
+
368
+ /**
369
+ * One dial, with the address PARSED INSIDE the promise.
370
+ *
371
+ * `multiaddr()` throws SYNCHRONOUSLY on a malformed string, and a throw out of
372
+ * the `map` callback escapes `Promise.allSettled` altogether — it would reject
373
+ * the whole drive (breaking the never-throws contract) and discard the dials to
374
+ * the other, perfectly good, addrs. `async` demotes it to a settled rejection,
375
+ * which is already exactly how a dial failure is reported.
376
+ */
377
+ async function dialOne(
378
+ node: Libp2p,
379
+ addr: string,
380
+ signal: AbortSignal
381
+ ): Promise<{ remotePeer: PeerId }> {
382
+ return node.dial(multiaddr(addr), { signal });
383
+ }
384
+
385
+ function connectedRelays(
386
+ addrs: readonly string[],
387
+ results: readonly PromiseSettledResult<{ remotePeer: PeerId }>[]
388
+ ): ConnectedRelay[] {
389
+ const connected: ConnectedRelay[] = [];
390
+ results.forEach((result, index) => {
391
+ if (result.status !== 'fulfilled') return;
392
+ connected.push({ addr: addrs[index], peerId: result.value.remotePeer });
393
+ });
394
+ return connected;
395
+ }
396
+
397
+ /** Log every dial rejection; return the first one in list order. */
398
+ function firstDialError(
399
+ addrs: readonly string[],
400
+ results: readonly PromiseSettledResult<unknown>[]
401
+ ): string | null {
402
+ let first: string | null = null;
403
+ results.forEach((result, index) => {
404
+ if (result.status !== 'rejected') return;
405
+ const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
406
+ log('Relay dial failed (%s): %s', addrs[index], message);
407
+ first ??= message;
408
+ });
409
+ return first;
410
+ }
411
+
412
+ /**
413
+ * Outcome of the explicit reservation request.
414
+ *
415
+ * `fatal` means no `/p2p-circuit` address can EVER appear (the node has no
416
+ * circuit-relay transport at all), so the caller must report immediately rather
417
+ * than spend the rest of the deadline polling for something that cannot happen.
418
+ * A rejected request is NOT fatal: discovery may still land a reservation for a
419
+ * relay that does happen to share this node's identify prefix.
420
+ */
421
+ interface ReservationAttempt {
422
+ error: string | null;
423
+ fatal: boolean;
424
+ }
425
+
426
+ /**
427
+ * Ask the connected relays, in list order, for a reservation slot; stop at the
428
+ * first success.
429
+ *
430
+ * `'discovered'`, not `'configured'`: the search listener's
431
+ * `relay:created-reservation` handler ignores `configured` reservations outright,
432
+ * so the `/p2p-circuit` listen address would never be published and
433
+ * `getMultiaddrs()` would stay empty even though the reservation succeeded.
434
+ * `'discovered'` consumes the pending-reservation id the bare `/p2p-circuit`
435
+ * listener registered, which is exactly what that handler matches on.
436
+ *
437
+ * Sequential, not concurrent: a bare `/p2p-circuit` listener registers exactly
438
+ * ONE pending reservation, so a second concurrent `addRelay` would be rejected
439
+ * with `HadEnoughRelaysError` — noise, not a failure. Running discovery is not a
440
+ * conflict either; `addRelay` dedupes through its own peer queue, so a relay that
441
+ * genuinely is discoverable still reserves exactly once.
442
+ */
443
+ async function requestReservation(
444
+ node: Libp2p,
445
+ connected: readonly ConnectedRelay[],
446
+ deadline: number
447
+ ): Promise<ReservationAttempt> {
448
+ if (connected.length === 0) {
449
+ // Nothing answered — the dial error is the whole story.
450
+ return { error: null, fatal: false };
451
+ }
452
+ const transport = findCircuitRelayTransport(node);
453
+ if (transport === null) {
454
+ return {
455
+ error: 'node has no circuit-relay transport — add circuitRelayTransport() to its transports',
456
+ fatal: true
457
+ };
458
+ }
459
+ let firstError: string | null = null;
460
+ for (const relay of connected) {
461
+ if (transport.reservationStore.hasReservation(relay.peerId)) {
462
+ return { error: null, fatal: false };
463
+ }
464
+ if (Date.now() >= deadline) {
465
+ break;
466
+ }
467
+ const failure = await requestOneReservation(transport.reservationStore, relay, deadline);
468
+ if (failure === null) {
469
+ return { error: null, fatal: false };
470
+ }
471
+ firstError ??= failure;
472
+ }
473
+ return { error: firstError, fatal: false };
474
+ }
475
+
476
+ /**
477
+ * One `addRelay` call, bounded by the drive's deadline. Returns `null` on
478
+ * success, or the reason it failed.
479
+ *
480
+ * Deadline-raced because `addRelay` runs on libp2p's own (much longer) reservation
481
+ * timeout and takes no signal: without the race a drive given 1.5 s would sit on a
482
+ * silent relay for libp2p's timeout instead. The abandoned promise stays handled —
483
+ * `Promise.race` attaches its own handlers — so a later rejection is not unhandled.
484
+ */
485
+ async function requestOneReservation(
486
+ store: RelayReservationStoreLike,
487
+ relay: ConnectedRelay,
488
+ deadline: number
489
+ ): Promise<string | null> {
490
+ let timer: ReturnType<typeof setTimeout> | undefined;
491
+ const expired = new Promise<typeof DEADLINE_PASSED>((resolve) => {
492
+ timer = setTimeout(() => resolve(DEADLINE_PASSED), Math.max(0, deadline - Date.now()));
493
+ });
494
+ try {
495
+ const outcome = await Promise.race([store.addRelay(relay.peerId, 'discovered'), expired]);
496
+ if (outcome === DEADLINE_PASSED) {
497
+ log('Relay reservation request (%s) still pending at the deadline', relay.addr);
498
+ return `relay reservation request to ${relay.addr} did not complete before the deadline`;
499
+ }
500
+ return null;
501
+ } catch (err) {
502
+ const message = describeReservationFailure(err, relay.addr);
503
+ log('Relay reservation failed (%s): %s', relay.addr, message);
504
+ return message;
505
+ } finally {
506
+ clearTimeout(timer);
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Turn libp2p's reservation rejection into a reason that names the actual cause.
512
+ *
513
+ * `HadEnoughRelaysError` needs translating rather than passing through: its own
514
+ * message ("we do not need any more relays") says the opposite of what happened.
515
+ * A `'discovered'` reservation is refused when the node holds NO pending
516
+ * reservation id, and a node holds one only while it listens on the bare
517
+ * `/p2p-circuit` search address — so the real cause is a missing listen address.
518
+ */
519
+ function describeReservationFailure(err: unknown, addr: string): string {
520
+ // NOTE: libp2p adds a peer to the reservation store's `relayFilter` when a
521
+ // request fails with `DialError` or `UnsupportedProtocolError`, and the failure
522
+ // path does not reset that filter (it only resets when a reservation is actually
523
+ // removed). So a SECOND drive against the same relay in the same process reports
524
+ // `ListenError: The relay was previously invalid` even if the relay has since
525
+ // recovered. `RelayReservationLoop` clears the entry before every attempt (see
526
+ // {@link clearRelayFilterEntry}); a caller that re-drives WITHOUT the supervisor
527
+ // still has to deal with the filter itself.
528
+ const name = err instanceof Error ? err.name : '';
529
+ const message = err instanceof Error ? err.message : String(err);
530
+ switch (name) {
531
+ case 'HadEnoughRelaysError':
532
+ return `relay reservation on ${addr} was not requested: this node holds no pending circuit reservation — it is not listening on the bare /p2p-circuit address`;
533
+ case 'UnsupportedProtocolError':
534
+ return `peer at ${addr} does not speak the circuit-relay hop protocol — it is not a relay`;
535
+ default:
536
+ return `relay reservation on ${addr} failed: ${message}`;
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Poll until the node advertises a `/p2p-circuit` address through one of `addrs`'
542
+ * relays ({@link circuitMultiaddrsVia}), or the deadline passes.
543
+ *
544
+ * Still a poll even though the reservation is now requested explicitly: `addRelay`
545
+ * resolving means the RELAY accepted, while the listen address is published a tick
546
+ * later by the listener's `relay:created-reservation` handler, and a reservation
547
+ * that discovery lands independently has no return value to await at all.
548
+ *
549
+ * NOTE: polls rather than subscribing to libp2p's `self:peer:update` — it keeps
550
+ * this free of libp2p event-name coupling. If 250 ms of reservation latency ever
551
+ * matters, switch to the event and keep the poll as a fallback.
552
+ */
553
+ async function waitForCircuitReservation(
554
+ node: Libp2p,
555
+ addrs: readonly string[],
556
+ deadline: number,
557
+ pollMs: number
558
+ ): Promise<boolean> {
559
+ for (;;) {
560
+ if (circuitMultiaddrsVia(node, addrs).length > 0) {
561
+ return true;
562
+ }
563
+ if (Date.now() >= deadline) {
564
+ return false;
565
+ }
566
+ await delay(Math.min(pollMs, Math.max(0, deadline - Date.now())));
567
+ }
568
+ }
569
+
570
+ function delay(ms: number): Promise<void> {
571
+ return new Promise((resolve) => setTimeout(resolve, ms));
572
+ }
573
+
574
+ /** Gap between liveness checks while a reservation is held. */
575
+ export const DEFAULT_RELAY_CHECK_MS = 5_000;
576
+ /** Backoff before the first re-drive after a failed attempt. */
577
+ export const DEFAULT_RELAY_MIN_BACKOFF_MS = 2_000;
578
+ /** Ceiling the backoff doubles up to. */
579
+ export const DEFAULT_RELAY_MAX_BACKOFF_MS = 60_000;
580
+
581
+ export interface RelayReservationSupervisorOptions extends RelayReserveOptions {
582
+ /** Gap between liveness checks while a reservation is held. Default 5_000. */
583
+ checkMs?: number;
584
+ /** Backoff before the first re-drive after a failure. Default 2_000. */
585
+ minBackoffMs?: number;
586
+ /** Backoff ceiling. Default 60_000. */
587
+ maxBackoffMs?: number;
588
+ /**
589
+ * Awaited before every drive AFTER the first — never before the first attempt,
590
+ * which the caller has typically prepared for already. A hook that throws or
591
+ * rejects is logged and the drive still runs: the hook is preparation, and a
592
+ * failed preparation must not cost the attempt itself.
593
+ *
594
+ * What a strand node uses it for: re-announcing its delegate peer id to the relay
595
+ * about to be re-dialed. A party control node running the relay server admits a
596
+ * strand node on an in-memory delegate grant, and a relay restart drops every
597
+ * grant it held — so a re-drive that did not re-announce first would be denied
598
+ * at the relay's connection gate.
599
+ */
600
+ beforeRedrive?: () => Promise<void> | void;
601
+ }
602
+
603
+ /**
604
+ * A running retry loop for one node + relay list. Obtained from
605
+ * {@link superviseRelayReservation}; its only imperative is {@link stop}.
606
+ */
607
+ export interface RelayReservationSupervisor {
608
+ /**
609
+ * Resolves once the FIRST attempt has settled — so a caller can await startup
610
+ * exactly as it awaited a single {@link driveRelayReservation}, and the retries
611
+ * carry on in the background afterwards. Also resolves on {@link stop}, so an
612
+ * awaiting caller is never left hanging.
613
+ */
614
+ readonly firstAttempt: Promise<void>;
615
+ /** True while a drive is in flight. */
616
+ readonly driving: boolean;
617
+ /** Epoch ms of the next scheduled tick; `null` while a drive is running. */
618
+ readonly retryAtMs: number | null;
619
+ /** Reason the last drive produced no reservation; `null` once one is held. */
620
+ readonly lastError: string | null;
621
+ /**
622
+ * Idempotent. Clears the timer so no further drive is scheduled. A drive that
623
+ * is ALREADY in flight is not aborted — {@link driveRelayReservation} takes no
624
+ * signal — but its result is discarded and nothing follows it.
625
+ */
626
+ stop(): void;
627
+ }
628
+
629
+ /**
630
+ * Keep asking for a relay reservation until one is held, then keep watching that
631
+ * it still is — the thing that makes a lost reservation recover on its own.
632
+ *
633
+ * Needed because nothing else re-drives: libp2p re-fills a search listener's
634
+ * freed slot from relay DISCOVERY, and a cadre node's namespaced identify puts
635
+ * discovery permanently out of reach (see the module header). Without this loop a
636
+ * relay restart left a browser tab undialable until the user reloaded the page.
637
+ *
638
+ * Per tick:
639
+ * - a reservation is held → reset the backoff, clear the error, re-check in
640
+ * `checkMs`. No drive: a healthy node must not re-request every few seconds.
641
+ * - otherwise → un-poison the reservation store's relay filter (see
642
+ * {@link clearRelayFilterEntry}) and run ONE {@link driveRelayReservation}.
643
+ * If that lands a reservation, rejoin the healthy path above; if not, sleep
644
+ * the current backoff and double it up to `maxBackoffMs`.
645
+ *
646
+ * "Held" is judged PER RELAY — a circuit addr through one of `addrs`' relays
647
+ * ({@link circuitMultiaddrsVia}) — so several loops over one node, one relay each
648
+ * (a strand node), each re-drive exactly the relay they own. Every drive after the
649
+ * first is preceded by the optional `beforeRedrive` hook
650
+ * ({@link RelayReservationSupervisorOptions}).
651
+ *
652
+ * Starts immediately; the first tick runs before this returns.
653
+ */
654
+ export function superviseRelayReservation(
655
+ node: Libp2p,
656
+ addrs: readonly string[],
657
+ opts?: RelayReservationSupervisorOptions
658
+ ): RelayReservationSupervisor {
659
+ return new RelayReservationLoop(node, [...addrs], opts ?? {});
660
+ }
661
+
662
+ /**
663
+ * Forget that `addr`'s peer ever failed a reservation request, so the next
664
+ * attempt is actually made rather than refused out of hand.
665
+ *
666
+ * `ReservationStore` records a peer in `relayFilter` when its reservation request
667
+ * fails with `DialError` or `UnsupportedProtocolError`, and NOTHING on that path
668
+ * ever clears it: the reset lives in `#checkReservationCount`, which only runs
669
+ * when a reservation is genuinely removed, and `#removeReservation` early-returns
670
+ * when there was never one. So a relay that was briefly not-a-relay (or died
671
+ * between our dial and the hop request) stays permanently rejected with
672
+ * `The relay was previously invalid` — measured, not inferred.
673
+ *
674
+ * Fails soft in every direction: no `relayFilter`, no `remove`, an addr with no
675
+ * peer id, a malformed addr — all mean "no un-poisoning", never a throw. The
676
+ * retry loop still recovers the common case without this, because a relay that is
677
+ * simply DOWN fails our own dial first and never reaches the filter.
678
+ *
679
+ * Returns whether an entry was actually removed (for specs; callers ignore it).
680
+ */
681
+ export function clearRelayFilterEntry(store: RelayReservationStoreLike, addr: string): boolean {
682
+ const filter = store.relayFilter;
683
+ if (typeof filter?.remove !== 'function') {
684
+ log('Reservation store has no removable relayFilter — cannot un-poison %s', addr);
685
+ return false;
686
+ }
687
+ try {
688
+ // The peer id is in the multiaddr, so this costs no dial and can run before one.
689
+ const peerId = trailingPeerId(multiaddr(addr));
690
+ if (peerId === null) {
691
+ // NOTE: an addr with no `/p2p/` component can still be dialed and reserved
692
+ // through (the peer id comes off the connection), but it can never be
693
+ // un-poisoned — so for THAT addr a poisoned filter is permanent again. No
694
+ // caller supplies such an addr today; if one ever does, resolve the peer id
695
+ // from the live connection instead of the multiaddr.
696
+ return false;
697
+ }
698
+ return filter.remove(peerIdFromString(peerId).toMultihash().bytes);
699
+ } catch (err) {
700
+ log('Could not clear the relayFilter entry for %s: %o', addr, err);
701
+ return false;
702
+ }
703
+ }
704
+
705
+ /** The self-rescheduling loop behind {@link superviseRelayReservation}. */
706
+ class RelayReservationLoop implements RelayReservationSupervisor {
707
+ readonly firstAttempt: Promise<void>;
708
+
709
+ private readonly checkMs: number;
710
+ private readonly minBackoffMs: number;
711
+ private readonly maxBackoffMs: number;
712
+ private backoffMs: number;
713
+ private timer: ReturnType<typeof setTimeout> | null = null;
714
+ private stopped = false;
715
+ private inFlight = false;
716
+ /** Drives started so far — the `beforeRedrive` hook runs from the second one on. */
717
+ private drives = 0;
718
+ private nextTickAtMs: number | null = null;
719
+ private failure: string | null = null;
720
+ private settleFirstAttempt: () => void = () => {};
721
+ private firstAttemptSettled = false;
722
+
723
+ constructor(
724
+ private readonly node: Libp2p,
725
+ private readonly addrs: readonly string[],
726
+ private readonly opts: RelayReservationSupervisorOptions
727
+ ) {
728
+ // Clamped away from 0: a caller-supplied 0 would spin the loop hot.
729
+ this.checkMs = Math.max(1, opts.checkMs ?? DEFAULT_RELAY_CHECK_MS);
730
+ this.minBackoffMs = Math.max(1, opts.minBackoffMs ?? DEFAULT_RELAY_MIN_BACKOFF_MS);
731
+ this.maxBackoffMs = Math.max(
732
+ this.minBackoffMs,
733
+ opts.maxBackoffMs ?? DEFAULT_RELAY_MAX_BACKOFF_MS
734
+ );
735
+ this.backoffMs = this.minBackoffMs;
736
+ this.firstAttempt = new Promise((resolve) => {
737
+ this.settleFirstAttempt = resolve;
738
+ });
739
+ void this.tick();
740
+ }
741
+
742
+ get driving(): boolean {
743
+ return this.inFlight;
744
+ }
745
+
746
+ get retryAtMs(): number | null {
747
+ return this.nextTickAtMs;
748
+ }
749
+
750
+ get lastError(): string | null {
751
+ return this.failure;
752
+ }
753
+
754
+ stop(): void {
755
+ if (this.stopped) {
756
+ return;
757
+ }
758
+ this.stopped = true;
759
+ if (this.timer !== null) {
760
+ clearTimeout(this.timer);
761
+ this.timer = null;
762
+ }
763
+ this.nextTickAtMs = null;
764
+ this.settleFirst();
765
+ }
766
+
767
+ private async tick(): Promise<void> {
768
+ if (this.stopped) {
769
+ return;
770
+ }
771
+ this.nextTickAtMs = null;
772
+ if (this.reservationHeld()) {
773
+ this.onReservationHeld();
774
+ return;
775
+ }
776
+ await this.driveOnce();
777
+ if (this.stopped) {
778
+ return;
779
+ }
780
+ this.settleFirst();
781
+ // A drive that LANDED a reservation resumes the healthy cadence immediately.
782
+ // Backing off here instead would leave the first liveness check after a long
783
+ // outage a fully grown backoff away (up to `maxBackoffMs`), so a reservation
784
+ // lost again right after recovery would go unnoticed for that whole window.
785
+ if (this.reservationHeld()) {
786
+ this.onReservationHeld();
787
+ return;
788
+ }
789
+ this.scheduleBackoff();
790
+ }
791
+
792
+ /** Per relay: only a circuit addr THROUGH one of this loop's relays counts. */
793
+ private reservationHeld(): boolean {
794
+ return circuitMultiaddrsVia(this.node, this.addrs).length > 0;
795
+ }
796
+
797
+ /** Healthy tick: nothing to request, so only reset and re-check later. */
798
+ private onReservationHeld(): void {
799
+ this.backoffMs = this.minBackoffMs;
800
+ this.failure = null;
801
+ this.settleFirst();
802
+ this.schedule(this.checkMs);
803
+ }
804
+
805
+ private async driveOnce(): Promise<void> {
806
+ this.inFlight = true;
807
+ try {
808
+ if (this.drives > 0) {
809
+ await this.runBeforeRedrive();
810
+ // A stop that arrived while the hook ran: nothing follows it, so do not dial
811
+ // a node that is being torn down.
812
+ if (this.stopped) {
813
+ return;
814
+ }
815
+ }
816
+ this.drives += 1;
817
+ this.unpoisonRelayFilter();
818
+ const { error } = await driveRelayReservation(this.node, this.addrs, this.opts);
819
+ if (!this.stopped) {
820
+ this.failure = error;
821
+ }
822
+ } catch (err) {
823
+ // `driveRelayReservation` is fail-soft by contract, so reaching here means
824
+ // that contract broke. The loop must survive it anyway: an escaping
825
+ // rejection would leave `firstAttempt` pending FOREVER (hanging every
826
+ // caller that awaits a reservation at startup) and schedule no further
827
+ // attempt, which is the failure this whole supervisor exists to prevent.
828
+ const message = err instanceof Error ? err.message : String(err);
829
+ log('Relay reservation drive threw, which it should not: %s', message);
830
+ if (!this.stopped) {
831
+ this.failure = message;
832
+ }
833
+ } finally {
834
+ this.inFlight = false;
835
+ }
836
+ }
837
+
838
+ private scheduleBackoff(): void {
839
+ const wait = this.backoffMs;
840
+ this.backoffMs = Math.min(this.backoffMs * 2, this.maxBackoffMs);
841
+ this.schedule(wait);
842
+ }
843
+
844
+ /** The caller's `beforeRedrive` hook, contained: a throw or rejection is logged, never propagated. */
845
+ private async runBeforeRedrive(): Promise<void> {
846
+ const hook = this.opts.beforeRedrive;
847
+ if (hook === undefined) {
848
+ return;
849
+ }
850
+ try {
851
+ await hook();
852
+ } catch (err) {
853
+ log('beforeRedrive hook failed (%o); driving the reservation anyway: %o', this.addrs, err);
854
+ }
855
+ }
856
+
857
+ private unpoisonRelayFilter(): void {
858
+ const transport = findCircuitRelayTransport(this.node);
859
+ if (transport === null) {
860
+ return;
861
+ }
862
+ for (const addr of this.addrs) {
863
+ clearRelayFilterEntry(transport.reservationStore, addr);
864
+ }
865
+ }
866
+
867
+ private schedule(ms: number): void {
868
+ if (this.stopped) {
869
+ return;
870
+ }
871
+ this.nextTickAtMs = Date.now() + ms;
872
+ this.timer = setTimeout(() => {
873
+ this.timer = null;
874
+ void this.tick();
875
+ }, ms);
876
+ // Node keeps the process alive while a timer is pending, so an un-unref'd
877
+ // supervisor would hang every CLI run and vitest worker that ever reserved.
878
+ // Cast + optional-chain: browsers and React Native have no `unref`.
879
+ (this.timer as { unref?: () => void }).unref?.();
880
+ }
881
+
882
+ private settleFirst(): void {
883
+ if (this.firstAttemptSettled) {
884
+ return;
885
+ }
886
+ this.firstAttemptSettled = true;
887
+ this.settleFirstAttempt();
888
+ }
889
+ }
890
+
891
+ /**
892
+ * Derive the reservation posture, reading the node's circuit addrs LIVE. Pure
893
+ * apart from that read, so the precedence is testable on its own:
894
+ *
895
+ * | condition | status |
896
+ * | ------------------------ | ----------------------------------------------- |
897
+ * | no addrs supplied | `none` |
898
+ * | live circuit addrs held | `reserved` (`error: null`) |
899
+ * | a drive is in flight | `dialing` |
900
+ * | a retry is scheduled | `retrying` |
901
+ * | otherwise | `error` |
902
+ *
903
+ * `reserved` is checked BEFORE `error` on purpose: the circuit addrs are read
904
+ * live, so a reservation that lands by ANY route supersedes a stale error string
905
+ * without a second drive.
906
+ *
907
+ * `retrying` sits between the two so that `error` carries a sharper meaning:
908
+ * NOBODY IS GOING TO TRY AGAIN. A lost reservation does come back on its own now,
909
+ * as long as a {@link RelayReservationSupervisor} is running — that loop is the
910
+ * only thing that re-drives, since libp2p re-fills a search listener's freed slot
911
+ * from relay DISCOVERY, which a cadre node's namespaced identify puts permanently
912
+ * out of reach (see the module header). So `error` means the supervisor was
913
+ * stopped, was never started, or there is no node at all.
914
+ *
915
+ * `retryAtMs` is the supervisor's next scheduled tick (`null` while a drive runs
916
+ * or when there is no supervisor); it both selects `retrying` and is passed
917
+ * through so a UI can count down to the next attempt.
918
+ */
919
+ export function resolveRelayReservationState(
920
+ node: Libp2p | null,
921
+ addrs: readonly string[],
922
+ lastError: string | null,
923
+ driving: boolean,
924
+ retryAtMs: number | null
925
+ ): RelayReservationState {
926
+ if (addrs.length === 0) {
927
+ return { status: 'none', addrs: [], circuitAddrs: [], error: null, retryAtMs: null };
928
+ }
929
+ const supplied = [...addrs];
930
+ const circuitAddrs = node ? circuitMultiaddrs(node) : [];
931
+ if (circuitAddrs.length > 0) {
932
+ return { status: 'reserved', addrs: supplied, circuitAddrs, error: null, retryAtMs };
933
+ }
934
+ if (driving) {
935
+ return {
936
+ status: 'dialing',
937
+ addrs: supplied,
938
+ circuitAddrs: [],
939
+ error: lastError,
940
+ retryAtMs: null
941
+ };
942
+ }
943
+ // `driveRelayReservation` always reports a reason when it finishes without a
944
+ // reservation, so `lastError` is normally set below. The fallback only covers
945
+ // addrs recorded without a completed drive.
946
+ const error = lastError ?? 'no circuit reservation held';
947
+ if (retryAtMs !== null) {
948
+ return { status: 'retrying', addrs: supplied, circuitAddrs: [], error, retryAtMs };
949
+ }
950
+ return { status: 'error', addrs: supplied, circuitAddrs: [], error, retryAtMs: null };
951
+ }