@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
@@ -41,8 +41,8 @@
41
41
  * `Memory*` / `Persistent*` / `File*` split, same party scoping. It does NOT share
42
42
  * their `NodeLocalSnapshot` machinery, for two reasons:
43
43
  *
44
- * - **Shape.** `NodeLocalSnapshot` is a `key -> entry` map with no delete. This
45
- * record is one integer that must be able to go DOWN after a machine is removed.
44
+ * - **Shape.** `NodeLocalSnapshot` is a `key -> entry` map. This record is one
45
+ * integer that must be able to go DOWN after a machine is removed.
46
46
  * - **Load policy — the important one.** `NodeLocalSnapshot.open` THROWS when the
47
47
  * slot is present but unreadable. That is right for the anchor (a record that
48
48
  * cannot be read in full is not a trust anchor) and right for the peer store (a
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ export { canonicalJson } from './canonical-json.js';
8
8
  export { CadreNode } from './cadre-node.js';
9
9
 
10
10
  // Control database
11
- export { ControlDatabase, MissingHostStrandError, buildAuthorizationMessage, formationVouchMessage, formationConsentMessage, REAPABLE_TABLES, type ReapableTable, type ControlDatabaseConfig, type ControlTable, type RevocableTable, type FormationUsageResult, type MembershipChangeListener, type GuardedDeleteListener, type RevokedRowRef } from './control-database.js';
11
+ export { ControlDatabase, MissingHostStrandError, buildAuthorizationMessage, formationVouchMessage, formationConsentMessage, REAPABLE_TABLES, type ReapableTable, type ControlDatabaseConfig, type ControlTable, type RevocableTable, type FormationUsageResult, type MembershipChangeListener, type GuardedDeleteListener, type ControlWriteAbandonedListener, type RevokedRowRef } from './control-database.js';
12
12
 
13
13
  // Bounded retry for transient control-write failures (classifier + loop behind
14
14
  // ControlDatabase.lockedWithRetry; exported so the integration package can drive the
@@ -35,6 +35,15 @@ export {
35
35
  type ControlReadRetryOptions
36
36
  } from './control-read-retry.js';
37
37
 
38
+ // The bounded retry loop both control policies share. Only the abandonment record leaves
39
+ // this package: it is the payload of the `control:write-abandoned` node event, so an
40
+ // embedding app can type a handler for it.
41
+ export type {
42
+ ControlRetryAbandonment,
43
+ ControlRetryAbandonReason,
44
+ ControlRetryAbandonListener
45
+ } from './control-retry.js';
46
+
38
47
  // Control-plane authorization field vector (the domain/action tagging every signer shares)
39
48
  export { controlAuthorizationFields, type ControlDomain, type ControlAction } from './control-authorization.js';
40
49
 
@@ -108,6 +117,11 @@ export {
108
117
  type EnrolledMachineStore
109
118
  } from './enrolled-machine-store.js';
110
119
 
120
+ // Storage scope keys — what `CadreNodeConfig.storage.provider` is called with. A
121
+ // strand's key is its strand id; the control database's key carries the party id,
122
+ // so two parties on one device never share a control store.
123
+ export { controlStorageScope, isControlStorageScope } from './storage-scope.js';
124
+
111
125
  // Strand database
112
126
  export { StrandDatabase, type StrandDatabaseConfig } from './strand-database.js';
113
127
 
@@ -123,9 +137,24 @@ export {
123
137
  StrandInstanceManager,
124
138
  type StartStrandConfig,
125
139
  type ResumeStrandOverrides,
126
- getStrandStoragePath
140
+ getStrandStoragePath,
141
+ isAwaitingFirstSync,
142
+ liveStrandStatus
127
143
  } from './strand-instance-manager.js';
128
144
 
145
+ // The joining machine's first-sync write gate: a non-founder launch withholds its
146
+ // database (status 'syncing') until the strand's Header has arrived from a peer, so a
147
+ // write before the first sync can never fork the strand's tables.
148
+ export {
149
+ StrandAwaitingFirstSyncError,
150
+ DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS,
151
+ DEFAULT_STRAND_FIRST_SYNC_POLL_MS,
152
+ strandHeaderHeld,
153
+ appTablesReadable,
154
+ strandFirstSyncComplete,
155
+ type StrandFirstSyncConfig
156
+ } from './strand-first-sync-gate.js';
157
+
129
158
  // Peer-join block catch-up (push this network's own blocks to each newly
130
159
  // connected peer, so a late joiner physically holds pre-join blocks). Shared
131
160
  // by the strand networks (ungated) and the control network (membership-gated).
@@ -157,6 +186,23 @@ export {
157
186
  type RevocationRefreshScheduler
158
187
  } from './strand-revocation-enforcer.js';
159
188
 
189
+ // Closed-strand membership reconciliation: the bring-up loop that finishes a
190
+ // party's join on every machine — redeem the staged formation invitation, then
191
+ // write the durable machine→party MemberPeer binding — without ever blocking
192
+ // bring-up (see the module doc for the pass ladder and terminal states).
193
+ export {
194
+ StrandMembershipReconciler,
195
+ IDLE_PASSES_BEFORE_ESCALATION,
196
+ INITIAL_JOIN_RETRY_INTERVAL_MS,
197
+ type StrandMembershipReconcilerDeps,
198
+ type StrandMembershipReconciliationConfig,
199
+ type PendingMembershipInviteSource
200
+ } from './strand-membership-reconciler.js';
201
+
202
+ // The timer seam the self-rescheduling strand bring-up loops share (first-sync gate
203
+ // probe loop, membership retry ladder) — injected by tests as a hand-cranked clock.
204
+ export { defaultTimeoutScheduler, type TimeoutScheduler } from './timeout-scheduler.js';
205
+
160
206
  // Hibernation
161
207
  export {
162
208
  HibernationManager,
@@ -220,8 +266,11 @@ export {
220
266
  signStrandApproval,
221
267
  generateStrandStampId,
222
268
  bootstrapFounderMembership,
269
+ PreSplitStrandIdentityError,
223
270
  issueInvite,
224
271
  consumeInvite,
272
+ burnInvite,
273
+ isStrandMember,
225
274
  cancelInvite,
226
275
  listOutstandingInvites,
227
276
  addMemberByManager,
@@ -235,6 +284,7 @@ export {
235
284
  removeManager,
236
285
  sealStrand,
237
286
  isStrandSealed,
287
+ StrandTransactionBusyError,
238
288
  STRAND_ENGINE,
239
289
  STRAND_ENGINE_VERSION,
240
290
  type FounderBootstrapParams,
@@ -253,7 +303,8 @@ export {
253
303
  type AddManagerParams,
254
304
  type AdmitManagerParams,
255
305
  type RemoveManagerParams,
256
- type SealStrandParams
306
+ type SealStrandParams,
307
+ type StrandWriteOptions
257
308
  } from './strand-membership-writer.js';
258
309
 
259
310
  // Engine-canonical datetime helper (shared by control + strand signed-write flows
@@ -367,12 +418,25 @@ export {
367
418
  // the cadence/degree defaults the proactive reconcile routine reads.
368
419
  export {
369
420
  selectControlCohortDials,
370
- DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS,
371
421
  DEFAULT_CONTROL_COHORT_RECONCILE_MS,
372
422
  DEFAULT_CONTROL_COHORT_TARGET_DEGREE,
373
- type ControlCohortSelection
423
+ type ControlCohortSelection,
424
+ type ControlCohortReconcileResult
374
425
  } from './control-cohort.js';
375
426
 
427
+ // Dialing one peer from several candidate addresses, each under its own time
428
+ // limit, so an address that never answers cannot starve the rest.
429
+ export {
430
+ dialPeerAddrs,
431
+ tryAddrsInTurn,
432
+ directBeforeRelayed,
433
+ DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS,
434
+ DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS,
435
+ DEFAULT_PEER_DIAL_BUDGET,
436
+ type PeerDialBudget,
437
+ type AddrDialer
438
+ } from './peer-dial.js';
439
+
376
440
  // Seed trust policy (trust anchor for incoming seeds)
377
441
  export {
378
442
  anchoredTrustPolicy,
@@ -396,6 +460,7 @@ export {
396
460
  FormationListener,
397
461
  dialFormation,
398
462
  isValidResponderCreatesResult,
463
+ isWellFormedMembershipInvite,
399
464
  sanitizeStrandAddrs,
400
465
  FORMATION_PROTOCOL,
401
466
  type FormationParty,
@@ -414,6 +479,9 @@ export {
414
479
  export {
415
480
  StrandFormationManager,
416
481
  createStrandFormationManager,
482
+ MEMBERSHIP_INVITE_TTL_MS,
483
+ MEMBERSHIP_INVITE_UNAVAILABLE_REASON,
484
+ HOST_STRAND_MUST_BE_RECREATED_REASON,
417
485
  type StrandFormationManagerConfig,
418
486
  type StrandFormationManagerOptions
419
487
  } from './strand-formation-manager.js';
@@ -438,11 +506,20 @@ export {
438
506
  // `UnbindableListenAddressError` is the same posture for the OTHER half of the listen
439
507
  // config: a `network.listenAddrs` entry whose transport the node will not have refuses
440
508
  // start instead of being silently dropped by libp2p's transport manager.
509
+ //
510
+ // `resolveListenAddrs` and `strandNodeAddrs` are the derivations themselves — what a
511
+ // `NetworkConfig` actually turns into for the control node and for each strand node.
512
+ // Exported so an embedder that hand-writes a `network` block (the React Native and web
513
+ // reference apps) can assert on the resulting SHAPE rather than on its own field names,
514
+ // which is the difference between a test that proves reachability and one that proves a
515
+ // spelling.
441
516
  export {
442
517
  RelayReservationFailedError,
443
518
  UnbindableListenAddressError,
444
- RELAY_SEARCH_LISTEN_ADDR
519
+ RELAY_SEARCH_LISTEN_ADDR,
520
+ resolveListenAddrs
445
521
  } from './relay-addrs.js';
522
+ export { strandNodeAddrs, type StrandNodeAddrs } from './strand-network-config.js';
446
523
 
447
524
  // Relay reservation via the bare `/p2p-circuit` search listener — the one route
448
525
  // every control node takes; `network.relayAddrs` is its fail-fast posture
@@ -104,9 +104,9 @@ export interface NodeLocalSnapshotSpec<E> {
104
104
  */
105
105
  export class NodeLocalSnapshot<E> {
106
106
  /**
107
- * Serialises persists: every {@link put} snapshot-writes the full entry set,
108
- * so chaining writes keeps them ordered and the last landed snapshot
109
- * complete.
107
+ * Serialises persists: every write ({@link put}, {@link remove}) snapshot-writes
108
+ * the full entry set, so chaining writes keeps them ordered and the last landed
109
+ * snapshot complete.
110
110
  */
111
111
  private writeChain: Promise<void> = Promise.resolve();
112
112
 
@@ -145,15 +145,15 @@ export class NodeLocalSnapshot<E> {
145
145
  return this.entries.has(key);
146
146
  }
147
147
 
148
- /** Fresh copy of the keys — a snapshot decoupled from later {@link put} calls. */
148
+ /** Fresh copy of the keys — a snapshot decoupled from later {@link put} / {@link remove} calls. */
149
149
  keySnapshot(): Set<string> {
150
150
  return new Set(this.entries.keys());
151
151
  }
152
152
 
153
153
  /**
154
154
  * Fresh copy of the `key -> entry` map — a snapshot decoupled from later
155
- * {@link put} calls. The entries themselves need no copy: {@link put}
156
- * REPLACES an entry rather than mutating it in place.
155
+ * {@link put} / {@link remove} calls. The entries themselves need no copy:
156
+ * {@link put} REPLACES an entry rather than mutating it in place.
157
157
  */
158
158
  entrySnapshot(): Map<string, E> {
159
159
  return new Map(this.entries);
@@ -164,12 +164,29 @@ export class NodeLocalSnapshot<E> {
164
164
  * synchronous caller may consult the store the moment this returns), then
165
165
  * the full snapshot is persisted. A persist failure rejects the returned
166
166
  * promise but leaves the entry in memory — this session's decision stands,
167
- * and any later successful {@link put} re-lands the complete set.
167
+ * and any later successful write re-lands the complete set.
168
168
  */
169
169
  put(key: string, entry: E): Promise<void> {
170
170
  this.entries.set(key, entry);
171
+ return this.queuePersist();
172
+ }
173
+
174
+ /**
175
+ * Remove an entry, with {@link put}'s contract: the in-memory map updates
176
+ * SYNCHRONOUSLY, then the full snapshot is persisted. Removing an absent key
177
+ * changes nothing, so it writes nothing.
178
+ */
179
+ remove(key: string): Promise<void> {
180
+ if (!this.entries.delete(key)) {
181
+ return Promise.resolve();
182
+ }
183
+ return this.queuePersist();
184
+ }
185
+
186
+ /** Chain a full-snapshot write behind every earlier one (see {@link put}). */
187
+ private queuePersist(): Promise<void> {
171
188
  const persist = this.writeChain.then(() => this.persistSnapshot());
172
- // The chain itself must survive a failed persist (the next put retries the
189
+ // The chain itself must survive a failed persist (the next write retries the
173
190
  // full snapshot); the caller still observes the rejection via `persist`.
174
191
  this.writeChain = persist.catch((error) => {
175
192
  log('%s: persist failed for party %s: %o', this.spec.label, this.partyId, error);
@@ -0,0 +1,215 @@
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 } from '@libp2p/interface';
28
+ import type { Multiaddr } from '@multiformats/multiaddr';
29
+ import { withDeadline } from './control-stream.js';
30
+
31
+ const log = debug('sereus:cadre:peer-dial');
32
+
33
+ /**
34
+ * Default limit on ONE address's dial attempt, in ms.
35
+ *
36
+ * It has to cover the slowest address that should succeed, because an attempt
37
+ * that needs longer fails the same way on every retry. That is a relayed dial on
38
+ * a mobile link: connect to the relay (transport, encryption and multiplexer
39
+ * handshakes, when no relay connection is open yet), open the circuit, then run
40
+ * both handshakes again end to end through the relay at roughly twice the
41
+ * round-trip time. At a few hundred ms per round trip that is several seconds.
42
+ * A direct dial that works is far quicker — a phone's WebSocket dial to a node
43
+ * forwarded over `adb reverse` measured 1.6 s end to end. 8 s covers the relayed
44
+ * case with some margin while still abandoning a silently dropped address
45
+ * sooner than libp2p's own 10 s dial timeout would.
46
+ *
47
+ * NOTE: a `/p2p-circuit/webrtc` address also negotiates a WebRTC session after
48
+ * the relay leg. If such dials are ever seen failing at exactly this limit, raise
49
+ * it (or `network.controlCohort.perAddressDialTimeoutMs` on the affected nodes)
50
+ * rather than the per-peer limit, which cannot help an address that is itself
51
+ * cut off.
52
+ *
53
+ * Override per node with `network.controlCohort.perAddressDialTimeoutMs`.
54
+ */
55
+ export const DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS = 8_000;
56
+
57
+ /**
58
+ * Default limit on dialing ONE peer — every candidate address together — in ms.
59
+ *
60
+ * Without it, an offline peer costs (address count × up to
61
+ * {@link DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS}), a number that
62
+ * depends on how many addresses the peer happens to report. Callers dial peers
63
+ * one after another (the control-cohort reconcile pass dials each sibling in
64
+ * turn), so that cost also delays every later peer. With the limit, a reconcile
65
+ * pass costs at most (dialed siblings) × this.
66
+ *
67
+ * Sized to fit a typical list with dead addresses ahead of the one that works:
68
+ * a lent cadre-host node reports two to four addresses a phone can dial
69
+ * (loopback and LAN WebSocket addresses, plus any virtual adapters). Three
70
+ * silently dropped addresses cost 3 × 8 s = 24 s, leaving 6 s for the working
71
+ * address after them — well over the 1.6 s the phone measured. A peer whose
72
+ * working address sits behind four or more dropped ones does not fit, and fails
73
+ * each pass the same way; if that shows up, the remedy is ordering (try the
74
+ * address that last connected first), not a larger number here.
75
+ *
76
+ * Override per node with `network.controlCohort.dialTimeoutMs` — tests that
77
+ * drive dead addresses on purpose set it low so a pass's duration is a chosen
78
+ * number rather than a transitive libp2p default stretched by machine load.
79
+ */
80
+ export const DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS = 30_000;
81
+
82
+ /** Time limits for dialing one peer from a list of candidate addresses. */
83
+ export interface PeerDialBudget {
84
+ /** Limit on one address's attempt, in ms. */
85
+ perAddressMs: number;
86
+ /** Limit on the whole peer — every address together — in ms. */
87
+ totalMs: number;
88
+ }
89
+
90
+ /** {@link PeerDialBudget} built from the two defaults above. */
91
+ export const DEFAULT_PEER_DIAL_BUDGET: Readonly<PeerDialBudget> = {
92
+ perAddressMs: DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS,
93
+ totalMs: DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS,
94
+ };
95
+
96
+ /** The slice of a libp2p node {@link dialPeerAddrs} uses. */
97
+ export interface AddrDialer {
98
+ dial(addr: Multiaddr, options: { signal: AbortSignal }): Promise<Connection>;
99
+ }
100
+
101
+ /** One candidate's outcome, kept so the thrown error can name every attempt. */
102
+ interface AddrAttemptFailure {
103
+ addr: Multiaddr;
104
+ error: Error;
105
+ }
106
+
107
+ /**
108
+ * Dial one peer from `addrs`, one address per `dial()` call, and return the
109
+ * first connection that forms. Direct addresses are tried before relayed ones
110
+ * ({@link directBeforeRelayed}); otherwise the given order is kept.
111
+ *
112
+ * Addresses naming a transport this node lacks are not filtered out first: a
113
+ * one-address `dial()` rejects those at once (`NoValidAddressesError`), so they
114
+ * cost a log line, not time.
115
+ *
116
+ * Throws when no address connects — see {@link tryAddrsInTurn} for the error.
117
+ *
118
+ * @param label Names the dial in timeout messages and logs, e.g.
119
+ * `reconcileControlCohort dial of sibling <peerId>`; each attempt reads
120
+ * `<label> via <addr>`.
121
+ */
122
+ export function dialPeerAddrs(
123
+ dialer: AddrDialer,
124
+ addrs: readonly Multiaddr[],
125
+ budget: PeerDialBudget,
126
+ label: string,
127
+ ): Promise<Connection> {
128
+ return tryAddrsInTurn(directBeforeRelayed(addrs), budget, label, (addr, signal) => dialer.dial(addr, { signal }));
129
+ }
130
+
131
+ /**
132
+ * Run `attempt` against each address in the given order until one succeeds, and
133
+ * return its result.
134
+ *
135
+ * Each attempt is limited to `budget.perAddressMs`, or to whatever remains of
136
+ * `budget.totalMs` when that is less. The attempt's signal is aborted when its
137
+ * limit passes, so a dial in progress is cancelled rather than left running.
138
+ * Addresses the total leaves no time for are reported as not tried.
139
+ *
140
+ * Throws when every address fails. With one address the error is that attempt's
141
+ * own, unchanged. With several it is one error naming each address and why it
142
+ * failed, in order, with the first failure as `cause` — so the report shows the
143
+ * address that mattered, not only whichever happened to be tried last.
144
+ *
145
+ * Between attempts this waits for one macrotask (`setTimeout(0)`). libp2p's dial
146
+ * queue lets a new `dial()` join any queued dial job for the same peer id
147
+ * WITHOUT checking whether that job has already finished, and an aborted job
148
+ * leaves the queue a few microtasks after its callers are rejected. Timing out
149
+ * one address and immediately dialing the next for the same peer therefore
150
+ * joins the dying job, which rejects the new dial with `AbortError` before it
151
+ * touches the network — the next address is never tried. Seen against libp2p
152
+ * 3.1.3; `test/peer-dial.spec.ts` dials real nodes so an upgrade that changes it
153
+ * shows up there. All of the queue's cleanup runs as microtasks, so it has
154
+ * finished by the time a macrotask runs.
155
+ */
156
+ export async function tryAddrsInTurn<T>(
157
+ addrs: readonly Multiaddr[],
158
+ budget: PeerDialBudget,
159
+ label: string,
160
+ attempt: (addr: Multiaddr, signal: AbortSignal, attemptMs: number) => Promise<T>,
161
+ ): Promise<T> {
162
+ const deadline = Date.now() + budget.totalMs;
163
+ const failures: AddrAttemptFailure[] = [];
164
+ for (const addr of addrs) {
165
+ const remaining = deadline - Date.now();
166
+ if (remaining <= 0) {
167
+ failures.push({ addr, error: new Error(`not tried — the ${budget.totalMs}ms budget for ${label} was spent on earlier addresses`) });
168
+ continue;
169
+ }
170
+ if (failures.length > 0) {
171
+ await nextMacrotask();
172
+ }
173
+ const attemptMs = Math.min(budget.perAddressMs, remaining);
174
+ try {
175
+ return await withDeadline(attemptMs, `${label} via ${addr.toString()}`, (signal) => attempt(addr, signal, attemptMs));
176
+ } catch (err) {
177
+ const error = err instanceof Error ? err : new Error(String(err));
178
+ failures.push({ addr, error });
179
+ log('%s via %s failed: %o', label, addr.toString(), error);
180
+ }
181
+ }
182
+ throw allAttemptsFailed(label, failures);
183
+ }
184
+
185
+ /**
186
+ * `addrs` with every circuit-relay address (one containing `/p2p-circuit`) moved
187
+ * after every direct one, each group keeping its given order.
188
+ *
189
+ * The one part of libp2p's own ordering worth keeping: a relayed connection is
190
+ * slower and limited in how much data and time it may carry, so a direct
191
+ * address is always worth trying first. libp2p's other rules are deliberately
192
+ * dropped — it puts loopback addresses last and public ones before private ones,
193
+ * which is how a forwarded loopback address ended up behind addresses that
194
+ * never answer. With a per-address limit, trying a dead address first costs a
195
+ * bounded amount of time, so the caller's order is the better guide.
196
+ */
197
+ export function directBeforeRelayed(addrs: readonly Multiaddr[]): Multiaddr[] {
198
+ const relayed = (addr: Multiaddr): boolean => addr.getComponents().some((c) => c.name === 'p2p-circuit');
199
+ return [...addrs.filter((addr) => !relayed(addr)), ...addrs.filter(relayed)];
200
+ }
201
+
202
+ function allAttemptsFailed(label: string, failures: AddrAttemptFailure[]): Error {
203
+ if (failures.length === 0) {
204
+ return new Error(`${label}: no candidate addresses`);
205
+ }
206
+ if (failures.length === 1) {
207
+ return failures[0].error;
208
+ }
209
+ const detail = failures.map((f) => `${f.addr.toString()} — ${f.error.message}`).join('; ');
210
+ return new Error(`${label} failed for all ${failures.length} candidate addresses: ${detail}`, { cause: failures[0].error });
211
+ }
212
+
213
+ function nextMacrotask(): Promise<void> {
214
+ return new Promise((resolve) => setTimeout(resolve, 0));
215
+ }