@serfab/cadre-core 1.9.0 → 1.11.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 (119) hide show
  1. package/dist/cadre-node.d.ts +148 -44
  2. package/dist/cadre-node.js +363 -139
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-authorization.d.ts +2 -2
  5. package/dist/control-authorization.js +1 -0
  6. package/dist/control-authorization.js.map +1 -1
  7. package/dist/control-cohort.d.ts +1 -1
  8. package/dist/control-cohort.js +1 -1
  9. package/dist/control-database.d.ts +105 -14
  10. package/dist/control-database.js +242 -73
  11. package/dist/control-database.js.map +1 -1
  12. package/dist/control-formation-recorder.d.ts +6 -6
  13. package/dist/control-formation-recorder.js +7 -8
  14. package/dist/control-formation-recorder.js.map +1 -1
  15. package/dist/control-schema.d.ts +1 -1
  16. package/dist/control-schema.js +1066 -979
  17. package/dist/control-schema.js.map +1 -1
  18. package/dist/control-write-retry.d.ts +21 -15
  19. package/dist/control-write-retry.js +21 -15
  20. package/dist/control-write-retry.js.map +1 -1
  21. package/dist/diagnostics/connection-path.d.ts +1 -18
  22. package/dist/diagnostics/connection-path.js +1 -20
  23. package/dist/diagnostics/connection-path.js.map +1 -1
  24. package/dist/identity-key.d.ts +0 -30
  25. package/dist/identity-key.js +7 -40
  26. package/dist/identity-key.js.map +1 -1
  27. package/dist/index.d.ts +8 -5
  28. package/dist/index.js +12 -6
  29. package/dist/index.js.map +1 -1
  30. package/dist/invitation-bootstrap.d.ts +32 -0
  31. package/dist/invitation-bootstrap.js +30 -0
  32. package/dist/invitation-bootstrap.js.map +1 -0
  33. package/dist/link-budget.d.ts +204 -119
  34. package/dist/link-budget.js +208 -120
  35. package/dist/link-budget.js.map +1 -1
  36. package/dist/membership-connection-gater.d.ts +3 -3
  37. package/dist/membership-connection-gater.js +3 -3
  38. package/dist/peer-dial.d.ts +18 -14
  39. package/dist/peer-dial.js +18 -14
  40. package/dist/peer-dial.js.map +1 -1
  41. package/dist/peer-join-backfill.d.ts +3 -3
  42. package/dist/pending-join-runner.d.ts +186 -0
  43. package/dist/pending-join-runner.js +649 -0
  44. package/dist/pending-join-runner.js.map +1 -0
  45. package/dist/push-fanout.d.ts +1 -1
  46. package/dist/push-fanout.js +1 -1
  47. package/dist/relay-stun.d.ts +24 -0
  48. package/dist/relay-stun.js +51 -0
  49. package/dist/relay-stun.js.map +1 -0
  50. package/dist/seed-bootstrap.d.ts +2 -2
  51. package/dist/seed-bootstrap.js +5 -5
  52. package/dist/strand-addr-protocol.js +2 -2
  53. package/dist/strand-formation-deadlines.d.ts +5 -5
  54. package/dist/strand-formation-deadlines.js +5 -5
  55. package/dist/strand-formation-manager.d.ts +18 -4
  56. package/dist/strand-formation-manager.js +48 -33
  57. package/dist/strand-formation-manager.js.map +1 -1
  58. package/dist/strand-formation-protocol.d.ts +71 -14
  59. package/dist/strand-formation-protocol.js +222 -47
  60. package/dist/strand-formation-protocol.js.map +1 -1
  61. package/dist/strand-formation-rejection.d.ts +94 -0
  62. package/dist/strand-formation-rejection.js +77 -0
  63. package/dist/strand-formation-rejection.js.map +1 -0
  64. package/dist/strand-instance-manager.js +4 -1
  65. package/dist/strand-instance-manager.js.map +1 -1
  66. package/dist/strand-membership-writer.js +32 -32
  67. package/dist/strand-solicitation.d.ts +11 -3
  68. package/dist/strand-solicitation.js +9 -0
  69. package/dist/strand-solicitation.js.map +1 -1
  70. package/dist/types.d.ts +103 -17
  71. package/dist/types.js +5 -9
  72. package/dist/types.js.map +1 -1
  73. package/package.json +22 -22
  74. package/src/cadre-node.ts +8654 -8400
  75. package/src/control-authorization.ts +2 -1
  76. package/src/control-cohort.ts +128 -128
  77. package/src/control-database.ts +303 -82
  78. package/src/control-formation-recorder.ts +353 -354
  79. package/src/control-read-retry.ts +216 -216
  80. package/src/control-schema.ts +1078 -991
  81. package/src/control-write-retry.ts +567 -561
  82. package/src/diagnostics/connection-path.ts +1 -29
  83. package/src/formation-approval.ts +610 -610
  84. package/src/hibernation-manager.ts +638 -638
  85. package/src/identity-key.ts +7 -62
  86. package/src/index.ts +644 -627
  87. package/src/invitation-bootstrap.ts +40 -0
  88. package/src/joined-strand-store.ts +498 -498
  89. package/src/link-budget.ts +684 -583
  90. package/src/membership-connection-gater.ts +570 -570
  91. package/src/node-local-snapshot.ts +298 -298
  92. package/src/peer-dial.ts +315 -311
  93. package/src/peer-join-backfill.ts +710 -710
  94. package/src/pending-join-runner.ts +735 -0
  95. package/src/push-fanout.ts +1 -1
  96. package/src/relay-reservation.ts +1121 -1121
  97. package/src/relay-server.ts +98 -98
  98. package/src/relay-stun.ts +58 -0
  99. package/src/seed-bootstrap.ts +1448 -1448
  100. package/src/strand-addr-protocol.ts +547 -547
  101. package/src/strand-database.ts +287 -287
  102. package/src/strand-first-sync-gate.ts +391 -391
  103. package/src/strand-formation-deadlines.ts +227 -227
  104. package/src/strand-formation-manager.ts +725 -700
  105. package/src/strand-formation-protocol.ts +1036 -802
  106. package/src/strand-formation-rejection.ts +129 -0
  107. package/src/strand-fret-addrs.ts +145 -145
  108. package/src/strand-instance-manager.ts +1789 -1786
  109. package/src/strand-membership-writer.ts +1974 -1974
  110. package/src/strand-network-state-file.ts +40 -40
  111. package/src/strand-network-state.ts +243 -243
  112. package/src/strand-revocation-enforcer.ts +654 -654
  113. package/src/strand-solicitation.ts +532 -519
  114. package/src/strand-wake-protocol.ts +401 -401
  115. package/src/types.ts +2222 -2140
  116. package/dist/diagnostics/webrtc-turn-tracker.d.ts +0 -50
  117. package/dist/diagnostics/webrtc-turn-tracker.js +0 -196
  118. package/dist/diagnostics/webrtc-turn-tracker.js.map +0 -1
  119. package/src/diagnostics/webrtc-turn-tracker.ts +0 -222
package/src/peer-dial.ts CHANGED
@@ -1,311 +1,315 @@
1
- /**
2
- * Dialing one peer from a list of candidate addresses, so that an address that
3
- * never answers cannot use up the time the others needed.
4
- *
5
- * libp2p's own multi-address dial (`libp2p.dial(addrs)`, libp2p 3.1.3's
6
- * `DialQueue.dialPeer`) tries the addresses ONE AT A TIME under ONE signal, with
7
- * no time limit per address. An address whose connection attempt is silently
8
- * dropped — a firewall, a subnet the dialer cannot route to, a virtual adapter's
9
- * address a Windows host reports alongside its real ones — holds that dial until
10
- * the whole signal expires, and every address sorted after it is never tried. It
11
- * also sorts the list itself (`defaultAddressSorter`), putting loopback addresses
12
- * last, behind exactly the LAN addresses most likely to be dropped. A phone
13
- * borrowing a node from a cadre-host whose only reachable address was the
14
- * forwarded loopback one never connected for this reason.
15
- *
16
- * So the addresses are dialed here, each as its own `dial()` with its own time
17
- * limit ({@link PeerDialBudget.perAddressMs}), inside a limit for the whole peer
18
- * ({@link PeerDialBudget.totalMs}). One address per call also means libp2p's
19
- * sorter has nothing to reorder, so the order is chosen here
20
- * ({@link directBeforeRelayed}).
21
- *
22
- * Dependency-free apart from `control-stream.ts`, so any module may import it
23
- * without creating an import cycle.
24
- */
25
-
26
- import debug from 'debug';
27
- import type { Connection, PeerId } from '@libp2p/interface';
28
- import { peerIdFromString } from '@libp2p/peer-id';
29
- import type { Component, Multiaddr } from '@multiformats/multiaddr';
30
- import { withDeadline } from './control-stream.js';
31
- import { relayedDialBudgetMs } from './link-budget.js';
32
-
33
- const log = debug('sereus:cadre:peer-dial');
34
-
35
- /**
36
- * Default limit on ONE address's dial attempt, in ms, at the DEFAULT declared link round trip:
37
- * `RELAYED_DIAL_ROUND_TRIPS` (4) x `DECLARED_LINK_ROUND_TRIP_MS` (3500 ms) plus the called
38
- * machine's `ADMISSION_DECISION_TIMEOUT_MS` (2000 ms) = 16 000 ms (`relayedDialBudgetMs`). It
39
- * was a fixed 8000 ms before it was derived.
40
- *
41
- * It has to cover the slowest address that should succeed, because an attempt
42
- * that needs longer fails the same way on every retry. That is a relayed dial on
43
- * a mobile link: connect to the relay (transport, encryption and multiplexer
44
- * handshakes, when no relay connection is open yet), open the circuit, then run
45
- * both handshakes again end to end through the relay. `link-budget.ts` is where that
46
- * reasoning is now a measured count instead of an estimate — four link round trips, measured at
47
- * 12 094 ms on a link whose round trip is 3 s — so the value MOVES with a host's
48
- * `NetworkConfig.linkRoundTripMs` rather than pinning the band this node can reach to whatever
49
- * number was typed here. A direct dial that works is far quicker — a phone's WebSocket dial to
50
- * a node forwarded over `adb reverse` measured 1.6 s end to end.
51
- *
52
- * Deriving it rather than leaving it fixed is deliberate: at 8000 ms fixed, the per-address
53
- * limit — not libp2p's own 10 s dial timeout — was the first thing to cut a relayed dial off,
54
- * from about 1000 ms of one-way link delay upward. Nothing said so, and a reader raising
55
- * `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` below would not have found it.
56
- *
57
- * NOTE: a `/p2p-circuit/webrtc` address also negotiates a WebRTC session after
58
- * the relay leg. If such dials are ever seen failing at exactly this limit, raise
59
- * the declared link round trip (or `network.controlCohort.perAddressDialTimeoutMs` on the
60
- * affected nodes) rather than the per-peer limit, which cannot help an address that is itself
61
- * cut off.
62
- *
63
- * Override per node with `network.controlCohort.perAddressDialTimeoutMs`.
64
- */
65
- export const DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS = relayedDialBudgetMs();
66
-
67
- /**
68
- * How many whole per-address dial attempts {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS}
69
- * budgets for: the address that works plus the three dead ones a lent cadre-host node is
70
- * observed to report ahead of it (loopback and LAN WebSocket addresses, plus virtual adapters).
71
- */
72
- export const CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS = 4;
73
-
74
- /**
75
- * Default limit on dialing ONE peer — every candidate address together — in ms.
76
- *
77
- * Without it, an offline peer costs (address count × up to
78
- * {@link DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS}), a number that
79
- * depends on how many addresses the peer happens to report. Callers dial peers
80
- * one after another (the control-cohort reconcile pass dials each sibling in
81
- * turn), so that cost also delays every later peer. With the limit, a reconcile
82
- * pass costs at most (dialed siblings) × this.
83
- *
84
- * Sized to fit a typical list with dead addresses ahead of the one that works:
85
- * a lent cadre-host node reports two to four addresses a phone can dial
86
- * (loopback and LAN WebSocket addresses, plus any virtual adapters). So it is
87
- * {@link CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS} whole per-address budgets — three silently
88
- * dropped addresses, then the one that works, each getting the FULL per-address limit. A peer
89
- * whose working address sits behind four or more dropped ones does not fit, and fails each pass
90
- * the same way; if that shows up, the remedy is ordering (try the address that last connected
91
- * first), not a larger number here.
92
- *
93
- * Counted as whole per-address budgets rather than the flat 30_000 it was before, because the
94
- * old number left only 6 s after three dropped addresses — enough for the 1.6 s direct dial the
95
- * phone measured, but NOT for the relayed dial the per-address limit is sized to cover. A peer
96
- * whose only working address was a relayed one behind two dead ones therefore could not be
97
- * dialled at all, and the arithmetic in this comment was what said otherwise.
98
- *
99
- * Override per node with `network.controlCohort.dialTimeoutMs` — tests that
100
- * drive dead addresses on purpose set it low so a pass's duration is a chosen
101
- * number rather than a transitive libp2p default stretched by machine load.
102
- */
103
- export const DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS =
104
- CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS * DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS;
105
-
106
- /** Time limits for dialing one peer from a list of candidate addresses. */
107
- export interface PeerDialBudget {
108
- /** Limit on one address's attempt, in ms. */
109
- perAddressMs: number;
110
- /** Limit on the whole peer — every address together — in ms. */
111
- totalMs: number;
112
- }
113
-
114
- /** {@link PeerDialBudget} built from the two defaults above. */
115
- export const DEFAULT_PEER_DIAL_BUDGET: Readonly<PeerDialBudget> = {
116
- perAddressMs: DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS,
117
- totalMs: DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS,
118
- };
119
-
120
- /** The slice of a libp2p node {@link dialPeerAddrs} uses. */
121
- export interface AddrDialer {
122
- /** This node's id, to recognise an address that relays through this node ({@link relaysThrough}). */
123
- readonly peerId: PeerId;
124
- dial(addr: Multiaddr, options: { signal: AbortSignal }): Promise<Connection>;
125
- }
126
-
127
- /**
128
- * Thrown by {@link dialPeerAddrs} when it was given addresses but every one reaches the peer by
129
- * relaying through this node, so none was dialed.
130
- *
131
- * This is where a relay lands for a peer that holds a reservation on it (a phone, or any node that
132
- * cannot listen): the address that peer publishes is `/<relay transport>/p2p/<relay>/p2p-circuit/p2p/<peer>`,
133
- * and dialing it from the relay itself fails with libp2p's `Can not dial self`. Nothing this node
134
- * does can repair it; once the peer's connection drops, only the peer can reconnect. Callers log it
135
- * as that rather than as a dial failure.
136
- */
137
- export class SelfRelayOnlyError extends Error {
138
- constructor(label: string) {
139
- super(`${label}: every candidate address relays through this node; only the peer can reconnect`);
140
- this.name = 'SelfRelayOnlyError';
141
- }
142
- }
143
-
144
- /** One candidate's outcome, kept so the thrown error can name every attempt. */
145
- interface AddrAttemptFailure {
146
- addr: Multiaddr;
147
- error: Error;
148
- }
149
-
150
- /**
151
- * Dial one peer from `addrs`, one address per `dial()` call, and return the
152
- * first connection that forms. Direct addresses are tried before relayed ones
153
- * ({@link directBeforeRelayed}); otherwise the given order is kept.
154
- *
155
- * Addresses that relay through this node are dropped first ({@link relaysThrough}):
156
- * dialing one could only fail with `Can not dial self`. When that leaves nothing
157
- * of a non-empty list, this throws {@link SelfRelayOnlyError} without dialing.
158
- *
159
- * Addresses naming a transport this node lacks are not filtered out first: a
160
- * one-address `dial()` rejects those at once (`NoValidAddressesError`), so they
161
- * cost a log line, not time.
162
- *
163
- * Otherwise throws when no address connects — see {@link tryAddrsInTurn} for the error.
164
- *
165
- * @param label Names the dial in timeout messages and logs, e.g.
166
- * `reconcileControlCohort dial of sibling <peerId>`; each attempt reads
167
- * `<label> via <addr>`.
168
- */
169
- export async function dialPeerAddrs(
170
- dialer: AddrDialer,
171
- addrs: readonly Multiaddr[],
172
- budget: PeerDialBudget,
173
- label: string,
174
- ): Promise<Connection> {
175
- const dialable = withoutSelfRelayed(dialer, addrs, label);
176
- if (addrs.length > 0 && dialable.length === 0) {
177
- throw new SelfRelayOnlyError(label);
178
- }
179
- return tryAddrsInTurn(directBeforeRelayed(dialable), budget, label, (addr, signal) => dialer.dial(addr, { signal }));
180
- }
181
-
182
- /**
183
- * Run `attempt` against each address in the given order until one succeeds, and
184
- * return its result.
185
- *
186
- * Each attempt is limited to `budget.perAddressMs`, or to whatever remains of
187
- * `budget.totalMs` when that is less. The attempt's signal is aborted when its
188
- * limit passes, so a dial in progress is cancelled rather than left running.
189
- * Addresses the total leaves no time for are reported as not tried.
190
- *
191
- * Throws when every address fails. With one address the error is that attempt's
192
- * own, unchanged. With several it is one error naming each address and why it
193
- * failed, in order, with the first failure as `cause` — so the report shows the
194
- * address that mattered, not only whichever happened to be tried last.
195
- *
196
- * Between attempts this waits for one macrotask (`setTimeout(0)`). libp2p's dial
197
- * queue lets a new `dial()` join any queued dial job for the same peer id
198
- * WITHOUT checking whether that job has already finished, and an aborted job
199
- * leaves the queue a few microtasks after its callers are rejected. Timing out
200
- * one address and immediately dialing the next for the same peer therefore
201
- * joins the dying job, which rejects the new dial with `AbortError` before it
202
- * touches the network — the next address is never tried. Seen against libp2p
203
- * 3.1.3; `test/peer-dial.spec.ts` dials real nodes so an upgrade that changes it
204
- * shows up there. All of the queue's cleanup runs as microtasks, so it has
205
- * finished by the time a macrotask runs.
206
- */
207
- export async function tryAddrsInTurn<T>(
208
- addrs: readonly Multiaddr[],
209
- budget: PeerDialBudget,
210
- label: string,
211
- attempt: (addr: Multiaddr, signal: AbortSignal, attemptMs: number) => Promise<T>,
212
- ): Promise<T> {
213
- const deadline = Date.now() + budget.totalMs;
214
- const failures: AddrAttemptFailure[] = [];
215
- for (const addr of addrs) {
216
- const remaining = deadline - Date.now();
217
- if (remaining <= 0) {
218
- failures.push({ addr, error: new Error(`not tried — the ${budget.totalMs}ms budget for ${label} was spent on earlier addresses`) });
219
- continue;
220
- }
221
- if (failures.length > 0) {
222
- await nextMacrotask();
223
- }
224
- const attemptMs = Math.min(budget.perAddressMs, remaining);
225
- try {
226
- return await withDeadline(attemptMs, `${label} via ${addr.toString()}`, (signal) => attempt(addr, signal, attemptMs));
227
- } catch (err) {
228
- const error = err instanceof Error ? err : new Error(String(err));
229
- failures.push({ addr, error });
230
- log('%s via %s failed: %o', label, addr.toString(), error);
231
- }
232
- }
233
- throw allAttemptsFailed(label, failures);
234
- }
235
-
236
- /**
237
- * `addrs` with every circuit-relay address (one containing `/p2p-circuit`) moved
238
- * after every direct one, each group keeping its given order.
239
- *
240
- * The one part of libp2p's own ordering worth keeping: a relayed connection is
241
- * slower and limited in how much data and time it may carry, so a direct
242
- * address is always worth trying first. libp2p's other rules are deliberately
243
- * dropped — it puts loopback addresses last and public ones before private ones,
244
- * which is how a forwarded loopback address ended up behind addresses that
245
- * never answer. With a per-address limit, trying a dead address first costs a
246
- * bounded amount of time, so the caller's order is the better guide.
247
- */
248
- export function directBeforeRelayed(addrs: readonly Multiaddr[]): Multiaddr[] {
249
- const relayed = (addr: Multiaddr): boolean => addr.getComponents().some((c) => c.name === 'p2p-circuit');
250
- return [...addrs.filter((addr) => !relayed(addr)), ...addrs.filter(relayed)];
251
- }
252
-
253
- /** `addrs` without those that relay through `dialer` itself, each dropped one logged. */
254
- function withoutSelfRelayed(dialer: AddrDialer, addrs: readonly Multiaddr[], label: string): Multiaddr[] {
255
- return addrs.filter((addr) => {
256
- if (!relaysThrough(addr, dialer.peerId)) {
257
- return true;
258
- }
259
- log('%s: skipping %s — it relays through this node', label, addr.toString());
260
- return false;
261
- });
262
- }
263
-
264
- /**
265
- * Whether `addr` reaches its target by relaying through `relay` at any hop.
266
- *
267
- * A circuit address names its relay in the `p2p` component immediately before
268
- * the `p2p-circuit` marker (`/<transport>/p2p/<relay>/p2p-circuit/p2p/<target>`),
269
- * so the components are walked rather than the text searched: the target's id
270
- * after the marker, a bare `/p2p-circuit` naming no relay, and a multi-hop chain
271
- * each read correctly that way. Same rule as db-p2p's `routesThroughRelay`, which
272
- * its package does not export.
273
- */
274
- function relaysThrough(addr: Multiaddr, relay: PeerId): boolean {
275
- const components = addr.getComponents();
276
- return components.some((component, i) => component.name === 'p2p-circuit' && namesPeer(components[i - 1], relay));
277
- }
278
-
279
- /**
280
- * Whether `component` is a `p2p` component naming `peer`. Compared as canonical
281
- * strings because a multiaddr keeps a peer id in whichever form it was written
282
- * (base58 or CIDv1). `PeerId.equals` is avoided because test doubles supply the
283
- * dialer's `peerId` with `toString` only.
284
- */
285
- function namesPeer(component: Component | undefined, peer: PeerId): boolean {
286
- if (component?.name !== 'p2p' || component.value === undefined) {
287
- return false;
288
- }
289
- const expected = peer.toString();
290
- try {
291
- return peerIdFromString(component.value).toString() === expected;
292
- } catch (err) {
293
- log('cannot read %s as a peer id; treating it as not this node: %o', component.value, err);
294
- return false;
295
- }
296
- }
297
-
298
- function allAttemptsFailed(label: string, failures: AddrAttemptFailure[]): Error {
299
- if (failures.length === 0) {
300
- return new Error(`${label}: no candidate addresses`);
301
- }
302
- if (failures.length === 1) {
303
- return failures[0].error;
304
- }
305
- const detail = failures.map((f) => `${f.addr.toString()} — ${f.error.message}`).join('; ');
306
- return new Error(`${label} failed for all ${failures.length} candidate addresses: ${detail}`, { cause: failures[0].error });
307
- }
308
-
309
- function nextMacrotask(): Promise<void> {
310
- return new Promise((resolve) => setTimeout(resolve, 0));
311
- }
1
+ /**
2
+ * Dialing one peer from a list of candidate addresses, so that an address that
3
+ * never answers cannot use up the time the others needed.
4
+ *
5
+ * libp2p's own multi-address dial (`libp2p.dial(addrs)`, `DialQueue.dialPeer`)
6
+ * tries the addresses ONE AT A TIME under ONE signal. Its only per-address limit
7
+ * is the connection manager's `addressDialTimeout` (libp2p 3.3+), which cadre
8
+ * sizes for a cold relayed open through two admission decisions
9
+ * (`optimysticDialLimits` in `link-budget.ts`: 39 s at the default declared
10
+ * link). An address whose connection attempt is silently dropped — a firewall, a
11
+ * subnet the dialer cannot route to, a virtual adapter's address a Windows host
12
+ * reports alongside its real ones — holds that dial for that long or until the
13
+ * whole signal expires, and the addresses sorted after it get what is left. It
14
+ * also sorts the list itself (`defaultAddressSorter`), putting loopback addresses
15
+ * last, behind exactly the LAN addresses most likely to be dropped. A phone
16
+ * borrowing a node from a cadre-host whose only reachable address was the
17
+ * forwarded loopback one never connected for this reason.
18
+ *
19
+ * So the addresses are dialed here, each as its own `dial()` with its own time
20
+ * limit ({@link PeerDialBudget.perAddressMs}), inside a limit for the whole peer
21
+ * ({@link PeerDialBudget.totalMs}). One address per call also means libp2p's
22
+ * sorter has nothing to reorder, so the order is chosen here
23
+ * ({@link directBeforeRelayed}).
24
+ *
25
+ * Dependency-free apart from `control-stream.ts`, so any module may import it
26
+ * without creating an import cycle.
27
+ */
28
+
29
+ import debug from 'debug';
30
+ import type { Connection, PeerId } from '@libp2p/interface';
31
+ import { peerIdFromString } from '@libp2p/peer-id';
32
+ import type { Component, Multiaddr } from '@multiformats/multiaddr';
33
+ import { withDeadline } from './control-stream.js';
34
+ import { relayedDialBudgetMs } from './link-budget.js';
35
+
36
+ const log = debug('sereus:cadre:peer-dial');
37
+
38
+ /**
39
+ * Default limit on ONE address's dial attempt, in ms, at the DEFAULT declared link round trip:
40
+ * `RELAYED_DIAL_ROUND_TRIPS` (5) x `DECLARED_LINK_ROUND_TRIP_MS` (3500 ms) plus
41
+ * `DIAL_ADMISSION_DECISIONS` (2) x `ADMISSION_DECISION_TIMEOUT_MS` (2000 ms) = 21 500 ms
42
+ * (`relayedDialBudgetMs`). It was a fixed 8000 ms before it was derived.
43
+ *
44
+ * It has to cover the slowest address that should succeed, because an attempt
45
+ * that needs longer fails the same way on every retry. That is a relayed dial on
46
+ * a mobile link: connect to the relay (transport, encryption and multiplexer
47
+ * handshakes, when no relay connection is open yet), open the circuit, then run
48
+ * both handshakes again end to end through the relay. `link-budget.ts` is where that
49
+ * reasoning is now a measured count instead of an estimate — five link round trips, measured at
50
+ * 15 113 ms on a link whose round trip is 3 s — so the value MOVES with a host's
51
+ * `NetworkConfig.linkRoundTripMs` rather than pinning the band this node can reach to whatever
52
+ * number was typed here. A direct dial that works is far quicker — a phone's WebSocket dial to
53
+ * a node forwarded over `adb reverse` measured 1.6 s end to end.
54
+ *
55
+ * Deriving it rather than leaving it fixed is deliberate: at 8000 ms fixed, the per-address
56
+ * limit — not libp2p's own 10 s dial timeout — was the first thing to cut a relayed dial off,
57
+ * from about 1000 ms of one-way link delay upward. Nothing said so, and a reader raising
58
+ * `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` below would not have found it.
59
+ *
60
+ * NOTE: a `/p2p-circuit/webrtc` address also negotiates a WebRTC session after
61
+ * the relay leg. If such dials are ever seen failing at exactly this limit, raise
62
+ * the declared link round trip (or `network.controlCohort.perAddressDialTimeoutMs` on the
63
+ * affected nodes) rather than the per-peer limit, which cannot help an address that is itself
64
+ * cut off.
65
+ *
66
+ * Override per node with `network.controlCohort.perAddressDialTimeoutMs`.
67
+ */
68
+ export const DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS = relayedDialBudgetMs();
69
+
70
+ /**
71
+ * How many whole per-address dial attempts {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS}
72
+ * budgets for: the address that works plus the three dead ones a lent cadre-host node is
73
+ * observed to report ahead of it (loopback and LAN WebSocket addresses, plus virtual adapters).
74
+ */
75
+ export const CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS = 4;
76
+
77
+ /**
78
+ * Default limit on dialing ONE peer — every candidate address together — in ms.
79
+ *
80
+ * Without it, an offline peer costs (address count × up to
81
+ * {@link DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS}), a number that
82
+ * depends on how many addresses the peer happens to report. Callers dial peers
83
+ * one after another (the control-cohort reconcile pass dials each sibling in
84
+ * turn), so that cost also delays every later peer. With the limit, a reconcile
85
+ * pass costs at most (dialed siblings) × this.
86
+ *
87
+ * Sized to fit a typical list with dead addresses ahead of the one that works:
88
+ * a lent cadre-host node reports two to four addresses a phone can dial
89
+ * (loopback and LAN WebSocket addresses, plus any virtual adapters). So it is
90
+ * {@link CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS} whole per-address budgets — three silently
91
+ * dropped addresses, then the one that works, each getting the FULL per-address limit. A peer
92
+ * whose working address sits behind four or more dropped ones does not fit, and fails each pass
93
+ * the same way; if that shows up, the remedy is ordering (try the address that last connected
94
+ * first), not a larger number here.
95
+ *
96
+ * Counted as whole per-address budgets rather than the flat 30_000 it was before, because the
97
+ * old number left only 6 s after three dropped addresses — enough for the 1.6 s direct dial the
98
+ * phone measured, but NOT for the relayed dial the per-address limit is sized to cover. A peer
99
+ * whose only working address was a relayed one behind two dead ones therefore could not be
100
+ * dialled at all, and the arithmetic in this comment was what said otherwise.
101
+ *
102
+ * Override per node with `network.controlCohort.dialTimeoutMs` — tests that
103
+ * drive dead addresses on purpose set it low so a pass's duration is a chosen
104
+ * number rather than a transitive libp2p default stretched by machine load.
105
+ */
106
+ export const DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS =
107
+ CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS * DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS;
108
+
109
+ /** Time limits for dialing one peer from a list of candidate addresses. */
110
+ export interface PeerDialBudget {
111
+ /** Limit on one address's attempt, in ms. */
112
+ perAddressMs: number;
113
+ /** Limit on the whole peer — every address together — in ms. */
114
+ totalMs: number;
115
+ }
116
+
117
+ /** {@link PeerDialBudget} built from the two defaults above. */
118
+ export const DEFAULT_PEER_DIAL_BUDGET: Readonly<PeerDialBudget> = {
119
+ perAddressMs: DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS,
120
+ totalMs: DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS,
121
+ };
122
+
123
+ /** The slice of a libp2p node {@link dialPeerAddrs} uses. */
124
+ export interface AddrDialer {
125
+ /** This node's id, to recognise an address that relays through this node ({@link relaysThrough}). */
126
+ readonly peerId: PeerId;
127
+ dial(addr: Multiaddr, options: { signal: AbortSignal }): Promise<Connection>;
128
+ }
129
+
130
+ /**
131
+ * Thrown by {@link dialPeerAddrs} when it was given addresses but every one reaches the peer by
132
+ * relaying through this node, so none was dialed.
133
+ *
134
+ * This is where a relay lands for a peer that holds a reservation on it (a phone, or any node that
135
+ * cannot listen): the address that peer publishes is `/<relay transport>/p2p/<relay>/p2p-circuit/p2p/<peer>`,
136
+ * and dialing it from the relay itself fails with libp2p's `Can not dial self`. Nothing this node
137
+ * does can repair it; once the peer's connection drops, only the peer can reconnect. Callers log it
138
+ * as that rather than as a dial failure.
139
+ */
140
+ export class SelfRelayOnlyError extends Error {
141
+ constructor(label: string) {
142
+ super(`${label}: every candidate address relays through this node; only the peer can reconnect`);
143
+ this.name = 'SelfRelayOnlyError';
144
+ }
145
+ }
146
+
147
+ /** One candidate's outcome, kept so the thrown error can name every attempt. */
148
+ interface AddrAttemptFailure {
149
+ addr: Multiaddr;
150
+ error: Error;
151
+ }
152
+
153
+ /**
154
+ * Dial one peer from `addrs`, one address per `dial()` call, and return the
155
+ * first connection that forms. Direct addresses are tried before relayed ones
156
+ * ({@link directBeforeRelayed}); otherwise the given order is kept.
157
+ *
158
+ * Addresses that relay through this node are dropped first ({@link relaysThrough}):
159
+ * dialing one could only fail with `Can not dial self`. When that leaves nothing
160
+ * of a non-empty list, this throws {@link SelfRelayOnlyError} without dialing.
161
+ *
162
+ * Addresses naming a transport this node lacks are not filtered out first: a
163
+ * one-address `dial()` rejects those at once (`NoValidAddressesError`), so they
164
+ * cost a log line, not time.
165
+ *
166
+ * Otherwise throws when no address connects — see {@link tryAddrsInTurn} for the error.
167
+ *
168
+ * @param label Names the dial in timeout messages and logs, e.g.
169
+ * `reconcileControlCohort dial of sibling <peerId>`; each attempt reads
170
+ * `<label> via <addr>`.
171
+ */
172
+ export async function dialPeerAddrs(
173
+ dialer: AddrDialer,
174
+ addrs: readonly Multiaddr[],
175
+ budget: PeerDialBudget,
176
+ label: string,
177
+ ): Promise<Connection> {
178
+ const dialable = withoutSelfRelayed(dialer, addrs, label);
179
+ if (addrs.length > 0 && dialable.length === 0) {
180
+ throw new SelfRelayOnlyError(label);
181
+ }
182
+ return tryAddrsInTurn(directBeforeRelayed(dialable), budget, label, (addr, signal) => dialer.dial(addr, { signal }));
183
+ }
184
+
185
+ /**
186
+ * Run `attempt` against each address in the given order until one succeeds, and
187
+ * return its result.
188
+ *
189
+ * Each attempt is limited to `budget.perAddressMs`, or to whatever remains of
190
+ * `budget.totalMs` when that is less. The attempt's signal is aborted when its
191
+ * limit passes, so a dial in progress is cancelled rather than left running.
192
+ * Addresses the total leaves no time for are reported as not tried.
193
+ *
194
+ * Throws when every address fails. With one address the error is that attempt's
195
+ * own, unchanged. With several it is one error naming each address and why it
196
+ * failed, in order, with the first failure as `cause` — so the report shows the
197
+ * address that mattered, not only whichever happened to be tried last.
198
+ *
199
+ * Between attempts this waits for one macrotask (`setTimeout(0)`). libp2p's dial
200
+ * queue lets a new `dial()` join any queued dial job for the same peer id
201
+ * WITHOUT checking whether that job has already finished, and an aborted job
202
+ * leaves the queue a few microtasks after its callers are rejected. Timing out
203
+ * one address and immediately dialing the next for the same peer therefore
204
+ * joins the dying job, which rejects the new dial with `AbortError` before it
205
+ * touches the network — the next address is never tried. Seen against libp2p
206
+ * 3.1.3, and the queue's join is unchanged in 3.3.11; `test/peer-dial.spec.ts`
207
+ * dials real nodes so an upgrade that changes it shows up there. All of the
208
+ * queue's cleanup runs as microtasks, so it has finished by the time a
209
+ * macrotask runs.
210
+ */
211
+ export async function tryAddrsInTurn<T>(
212
+ addrs: readonly Multiaddr[],
213
+ budget: PeerDialBudget,
214
+ label: string,
215
+ attempt: (addr: Multiaddr, signal: AbortSignal, attemptMs: number) => Promise<T>,
216
+ ): Promise<T> {
217
+ const deadline = Date.now() + budget.totalMs;
218
+ const failures: AddrAttemptFailure[] = [];
219
+ for (const addr of addrs) {
220
+ const remaining = deadline - Date.now();
221
+ if (remaining <= 0) {
222
+ failures.push({ addr, error: new Error(`not tried — the ${budget.totalMs}ms budget for ${label} was spent on earlier addresses`) });
223
+ continue;
224
+ }
225
+ if (failures.length > 0) {
226
+ await nextMacrotask();
227
+ }
228
+ const attemptMs = Math.min(budget.perAddressMs, remaining);
229
+ try {
230
+ return await withDeadline(attemptMs, `${label} via ${addr.toString()}`, (signal) => attempt(addr, signal, attemptMs));
231
+ } catch (err) {
232
+ const error = err instanceof Error ? err : new Error(String(err));
233
+ failures.push({ addr, error });
234
+ log('%s via %s failed: %o', label, addr.toString(), error);
235
+ }
236
+ }
237
+ throw allAttemptsFailed(label, failures);
238
+ }
239
+
240
+ /**
241
+ * `addrs` with every circuit-relay address (one containing `/p2p-circuit`) moved
242
+ * after every direct one, each group keeping its given order.
243
+ *
244
+ * The one part of libp2p's own ordering worth keeping: a relayed connection is
245
+ * slower and limited in how much data and time it may carry, so a direct
246
+ * address is always worth trying first. libp2p's other rules are deliberately
247
+ * dropped — it puts loopback addresses last and public ones before private ones,
248
+ * which is how a forwarded loopback address ended up behind addresses that
249
+ * never answer. With a per-address limit, trying a dead address first costs a
250
+ * bounded amount of time, so the caller's order is the better guide.
251
+ */
252
+ export function directBeforeRelayed(addrs: readonly Multiaddr[]): Multiaddr[] {
253
+ const relayed = (addr: Multiaddr): boolean => addr.getComponents().some((c) => c.name === 'p2p-circuit');
254
+ return [...addrs.filter((addr) => !relayed(addr)), ...addrs.filter(relayed)];
255
+ }
256
+
257
+ /** `addrs` without those that relay through `dialer` itself, each dropped one logged. */
258
+ function withoutSelfRelayed(dialer: AddrDialer, addrs: readonly Multiaddr[], label: string): Multiaddr[] {
259
+ return addrs.filter((addr) => {
260
+ if (!relaysThrough(addr, dialer.peerId)) {
261
+ return true;
262
+ }
263
+ log('%s: skipping %s — it relays through this node', label, addr.toString());
264
+ return false;
265
+ });
266
+ }
267
+
268
+ /**
269
+ * Whether `addr` reaches its target by relaying through `relay` at any hop.
270
+ *
271
+ * A circuit address names its relay in the `p2p` component immediately before
272
+ * the `p2p-circuit` marker (`/<transport>/p2p/<relay>/p2p-circuit/p2p/<target>`),
273
+ * so the components are walked rather than the text searched: the target's id
274
+ * after the marker, a bare `/p2p-circuit` naming no relay, and a multi-hop chain
275
+ * each read correctly that way. Same rule as db-p2p's `routesThroughRelay`, which
276
+ * its package does not export.
277
+ */
278
+ function relaysThrough(addr: Multiaddr, relay: PeerId): boolean {
279
+ const components = addr.getComponents();
280
+ return components.some((component, i) => component.name === 'p2p-circuit' && namesPeer(components[i - 1], relay));
281
+ }
282
+
283
+ /**
284
+ * Whether `component` is a `p2p` component naming `peer`. Compared as canonical
285
+ * strings because a multiaddr keeps a peer id in whichever form it was written
286
+ * (base58 or CIDv1). `PeerId.equals` is avoided because test doubles supply the
287
+ * dialer's `peerId` with `toString` only.
288
+ */
289
+ function namesPeer(component: Component | undefined, peer: PeerId): boolean {
290
+ if (component?.name !== 'p2p' || component.value === undefined) {
291
+ return false;
292
+ }
293
+ const expected = peer.toString();
294
+ try {
295
+ return peerIdFromString(component.value).toString() === expected;
296
+ } catch (err) {
297
+ log('cannot read %s as a peer id; treating it as not this node: %o', component.value, err);
298
+ return false;
299
+ }
300
+ }
301
+
302
+ function allAttemptsFailed(label: string, failures: AddrAttemptFailure[]): Error {
303
+ if (failures.length === 0) {
304
+ return new Error(`${label}: no candidate addresses`);
305
+ }
306
+ if (failures.length === 1) {
307
+ return failures[0].error;
308
+ }
309
+ const detail = failures.map((f) => `${f.addr.toString()} — ${f.error.message}`).join('; ');
310
+ return new Error(`${label} failed for all ${failures.length} candidate addresses: ${detail}`, { cause: failures[0].error });
311
+ }
312
+
313
+ function nextMacrotask(): Promise<void> {
314
+ return new Promise((resolve) => setTimeout(resolve, 0));
315
+ }