@serfab/cadre-core 0.13.0 → 1.1.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 +176 -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 +533 -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
@@ -19,16 +19,32 @@
19
19
  * The reachability model this rests on: **a strand node is not separately dialable
20
20
  * at a published fixed address.** It is reached through (a) its ephemeral direct
21
21
  * listener plus the addresses peers observe for it, and (b) circuit relay —
22
- * `network.relayAddrs` resolves to per-relay `<relay>/p2p-circuit` listen entries
23
- * that strand nodes DO inherit correctly, because the port inside such an entry is
24
- * the relay's, not a local bind (`relay-addrs.ts`).
22
+ * `network.relayAddrs` resolves to ONE bare `/p2p-circuit` SEARCH listen entry PER
23
+ * relay, and the strand runtime (`strand-instance-manager.ts`) runs one
24
+ * reservation supervisor per relay over the dial addrs this module returns beside
25
+ * them ({@link StrandNodeAddrs.relayAddrs}). The same route the control node takes,
26
+ * for the same reason the configured `<relay>/p2p-circuit` shape was retired
27
+ * everywhere (`relay-addrs.ts`): a configured listener loses its address on a relay
28
+ * restart, on either side hanging up, and on libp2p's own reservation refresh, and
29
+ * recovers from none of them. One search entry per relay rather than one for all,
30
+ * because a search listener registers exactly ONE pending reservation and libp2p
31
+ * fills a pending reservation with exactly one relay — so N relays need N listeners,
32
+ * each with a supervisor that owns it.
25
33
  *
26
- * Everything else in `NetworkConfig` — `relayAddrs`, `transports`,
27
- * `connectionGater`, `enableRelay` — is inherited by the caller; this module
28
- * only owns the two host-endpoint fields above. One caveat on `connectionGater`:
29
- * an OPEN strand's node gets it unchanged, while a CLOSED strand's node composes
30
- * revoked-peer denial onto it in `strand-instance-manager.ts` (every supplied
31
- * hook still honored — see `strand-revocation-enforcer.ts`).
34
+ * A hand-written `<relay>/p2p-circuit` entry in `listenAddrs` counts as a relay
35
+ * (its dial prefix is supervised like a `relayAddrs` entry) — the control node
36
+ * rejects that entry outright, so in production it never reaches here, but a
37
+ * strand built straight from such a config must not get an unsupervised configured
38
+ * listener either. A hand-written BARE `/p2p-circuit` (the browser shape,
39
+ * `reference-app-web`) passes through unchanged when no relay is named, and is
40
+ * absorbed by the per-relay entries when one is.
41
+ *
42
+ * Everything else in `NetworkConfig` — `transports`, `connectionGater`,
43
+ * `enableRelay` — is inherited by the caller; this module only owns the two
44
+ * host-endpoint fields above and the relay listen shape. One caveat on
45
+ * `connectionGater`: an OPEN strand's node gets it unchanged, while a CLOSED strand's
46
+ * node composes revoked-peer denial onto it in `strand-instance-manager.ts` (every
47
+ * supplied hook still honored — see `strand-revocation-enforcer.ts`).
32
48
  *
33
49
  * It does carry one non-address option out with them: the WebSocket transport switch
34
50
  * a `/ws` listen entry implies (`relay-addrs.ts` → `resolveTransportOptions`). That is
@@ -38,7 +54,14 @@
38
54
  */
39
55
 
40
56
  import { multiaddr, type Component } from '@multiformats/multiaddr';
41
- import { resolveListenAddrs, resolveTransportOptions } from './relay-addrs.js';
57
+ import {
58
+ DEFAULT_DIRECT_LISTEN_ADDR,
59
+ RELAY_SEARCH_LISTEN_ADDR,
60
+ isConfiguredCircuitListenAddr,
61
+ relayCircuitAddrs,
62
+ resolveTransportOptions
63
+ } from './relay-addrs.js';
64
+ import { extractCircuitRelayTargets } from './delegate-admission.js';
42
65
  import type { NetworkConfig } from './types.js';
43
66
 
44
67
  /**
@@ -59,7 +82,9 @@ export interface StrandNodeAddrs {
59
82
  /**
60
83
  * Listen entries for this strand node, or `undefined` when the operator
61
84
  * configured neither `listenAddrs` nor `relayAddrs` — in which case the caller
62
- * omits the option and inherits `@optimystic/db-p2p`'s own default.
85
+ * omits the option and inherits `@optimystic/db-p2p`'s own default. Carries one
86
+ * bare `/p2p-circuit` entry per entry of {@link relayAddrs}, deliberately NOT
87
+ * deduplicated: each one is its own listener with its own pending reservation.
63
88
  */
64
89
  listenAddrs?: string[];
65
90
  /**
@@ -70,14 +95,28 @@ export interface StrandNodeAddrs {
70
95
  * `/ws` strand listen entry binds nothing and libp2p reports nothing.
71
96
  */
72
97
  wsPort?: number;
98
+ /**
99
+ * The relays this strand node reserves through, as DIRECT dial addrs
100
+ * (`<host>/p2p/<relayPeerId>`, no `/p2p-circuit`), one per relay, in config order,
101
+ * deduplicated by relay peer id — the union of `network.relayAddrs` and any
102
+ * hand-written `<relay>/p2p-circuit` entry in `network.listenAddrs`. Present only
103
+ * when at least one relay is named.
104
+ *
105
+ * NOT a `createLibp2pNode` option: the caller (`strand-instance-manager.ts`)
106
+ * destructures it off before spreading the rest, and starts one
107
+ * `superviseRelayReservation` per entry over the node it built.
108
+ */
109
+ relayAddrs?: string[];
73
110
  }
74
111
 
75
112
  /**
76
- * The strand-node view of `network`: the control node's resolved listen entries with
77
- * every fixed direct port rewritten to an ephemeral one, and no announce fields.
113
+ * The strand-node view of `network`: the operator's direct listen entries with every
114
+ * fixed port rewritten to an ephemeral one, one bare `/p2p-circuit` search entry per
115
+ * relay, the relay dial addrs those entries are supervised against, and no announce
116
+ * fields.
78
117
  *
79
- * Validation is unchanged and still fail-fast — `resolveListenAddrs` throws here on a
80
- * malformed `relayAddrs` entry exactly as it does for the control node.
118
+ * Validation is fail-fast exactly as it is for the control node — `relayCircuitAddrs`
119
+ * throws here on a malformed `relayAddrs` entry.
81
120
  *
82
121
  * NOTE: announce entries are no longer validated on this path at all; the CONTROL
83
122
  * node's own build (`cadre-node.ts`) is the only thing that parses them, and it runs
@@ -87,22 +126,61 @@ export interface StrandNodeAddrs {
87
126
  * would then go unreported until the control node's own build.
88
127
  */
89
128
  export function strandNodeAddrs(network: NetworkConfig | undefined): StrandNodeAddrs {
90
- // The default `'configured'` relay route, deliberately NOT the control node's
91
- // `'search'` route: nothing drives an explicit reservation for a strand node, so a
92
- // bare `/p2p-circuit` search entry would register a pending reservation nobody fills
93
- // and leave every NAT'd strand node undialable. See `relay-addrs.ts`.
94
- const listenAddrs = resolveListenAddrs(network);
95
- if (!listenAddrs) {
129
+ const relayAddrs = strandRelayAddrs(network);
130
+ const configured = network?.listenAddrs;
131
+ if (!configured && relayAddrs.length === 0) {
96
132
  return {};
97
133
  }
98
134
  // An explicitly empty `listenAddrs` (the React Native "cannot listen" case) stays
99
135
  // empty — rewriting must never resurrect a direct listener that was opted out of.
100
- const rewritten = dedupe(listenAddrs.map(ephemeralPortListenAddr));
136
+ // Naming a relay with no `listenAddrs` keeps the direct listener the control node
137
+ // keeps in that case (`relay-addrs.ts`), so a relay ADDS reachability rather than
138
+ // replacing it.
139
+ const direct = configured ?? [DEFAULT_DIRECT_LISTEN_ADDR];
140
+ // Every circuit entry — the configured shape (now a relay in `relayAddrs`) and a
141
+ // hand-written bare search entry alike — is replaced by the per-relay search entries
142
+ // once a relay is named; with none, the operator's entries pass through as written.
143
+ const kept = relayAddrs.length > 0 ? direct.filter((addr) => !isCircuitListenAddr(addr)) : direct;
144
+ // The search entries are appended AFTER the dedupe on purpose: they are identical
145
+ // strings, and each one must survive as its own listener (see the module doc).
146
+ //
147
+ // NOTE: every bare entry also makes libp2p start its own relay discovery whenever
148
+ // that listener's slot is empty (launch, and every loss). Measured on a node with
149
+ // no peer router: one peer-store scan plus a random walk that fails at once
150
+ // (`NoPeerRoutersError`) per loss event — no dial churn. If strand nodes ever get
151
+ // a peer router (a DHT), that walk turns into real dials on every relay loss;
152
+ // cap or disable `circuitRelayTransport`'s discovery then.
153
+ const listenAddrs = [
154
+ ...dedupe(kept.map(ephemeralPortListenAddr)),
155
+ ...relayAddrs.map(() => RELAY_SEARCH_LISTEN_ADDR)
156
+ ];
101
157
  // Classified AFTER the ephemeral rewrite, so the check reads what this strand node
102
158
  // will actually bind. Zeroing a port cannot change an entry's transport — only the
103
159
  // `tcp`/`udp` component's value moves — so `/ip4/0.0.0.0/tcp/4002/ws` still resolves
104
- // to WebSocket as `/ip4/0.0.0.0/tcp/0/ws`.
105
- return { listenAddrs: rewritten, ...resolveTransportOptions(network, rewritten) };
160
+ // to WebSocket as `/ip4/0.0.0.0/tcp/0/ws`; a bare `/p2p-circuit` is `'circuit'`.
161
+ return {
162
+ listenAddrs,
163
+ ...resolveTransportOptions(network, listenAddrs),
164
+ ...(relayAddrs.length > 0 && { relayAddrs })
165
+ };
166
+ }
167
+
168
+ /**
169
+ * The relays a strand node built from `network` reserves through, as direct dial
170
+ * addrs: every `relayAddrs` entry (validated — a malformed one throws, naming the
171
+ * field) plus the relay named by any hand-written `<relay>/p2p-circuit` listen entry,
172
+ * deduplicated by relay PEER ID with the first spelling winning — two spellings of one
173
+ * relay would otherwise get two supervisors fighting over one reservation slot.
174
+ */
175
+ function strandRelayAddrs(network: NetworkConfig | undefined): string[] {
176
+ const configured = relayCircuitAddrs(network?.relayAddrs ?? []);
177
+ const handWritten = (network?.listenAddrs ?? []).filter(isConfiguredCircuitListenAddr);
178
+ return extractCircuitRelayTargets([...configured, ...handWritten]).map((relay) => relay.relayAddr);
179
+ }
180
+
181
+ /** Any `/p2p-circuit` listen entry — bare or configured; an unparsable entry is not one. */
182
+ function isCircuitListenAddr(listenAddr: string): boolean {
183
+ return listenAddr === RELAY_SEARCH_LISTEN_ADDR || isConfiguredCircuitListenAddr(listenAddr);
106
184
  }
107
185
 
108
186
  /**
@@ -7,7 +7,8 @@ import { sign } from '@optimystic/quereus-plugin-crypto';
7
7
  import type {
8
8
  OpenInvitation,
9
9
  FormStrandResult,
10
- StrandFormationDisclosure
10
+ StrandFormationDisclosure,
11
+ StrandMembershipInvite
11
12
  } from './types.js';
12
13
  // control-database does not import this service, so these imports introduce no cycle.
13
14
  import { generateStampId, formationConsentMessage } from './control-database.js';
@@ -199,6 +200,14 @@ export interface StrandSolicitationServiceOptions {
199
200
  * by `CadreNode`; unwired, the responder discloses no strand addresses.
200
201
  */
201
202
  resolveStrandAddrs?: (strandId: string) => string[];
203
+ /**
204
+ * Issue a single-use strand membership invitation against the live host strand a
205
+ * bound closed-strand redemption resolved to (responder side). Wired by `CadreNode`;
206
+ * see `StrandFormationManagerOptions.issueMembershipInvite` for the full contract
207
+ * (null = open strand, throw = reject the redemption retryably, unwired = mock/test
208
+ * posture: approve with no invitation).
209
+ */
210
+ issueMembershipInvite?: (strandId: string) => Promise<StrandMembershipInvite | null>;
202
211
  /** Configuration for the formation manager */
203
212
  formationConfig?: StrandFormationManagerConfig;
204
213
  }
@@ -224,6 +233,7 @@ export class StrandSolicitationService {
224
233
  private readonly partyId: string;
225
234
  private readonly cadrePeerAddrs: string[];
226
235
  private readonly resolveStrandAddrs?: (strandId: string) => string[];
236
+ private readonly issueMembershipInvite?: (strandId: string) => Promise<StrandMembershipInvite | null>;
227
237
  private formationManager?: StrandFormationManager;
228
238
  private readonly formationConfig?: StrandFormationManagerConfig;
229
239
  /**
@@ -247,6 +257,7 @@ export class StrandSolicitationService {
247
257
  this.partyId = options?.partyId ?? `party-${Date.now()}`;
248
258
  this.cadrePeerAddrs = options?.cadrePeerAddrs ?? [];
249
259
  this.resolveStrandAddrs = options?.resolveStrandAddrs;
260
+ this.issueMembershipInvite = options?.issueMembershipInvite;
250
261
  this.formationConfig = options?.formationConfig;
251
262
  log('StrandSolicitationService created for party: %s', this.partyId);
252
263
  }
@@ -265,6 +276,7 @@ export class StrandSolicitationService {
265
276
  partyId: this.partyId,
266
277
  cadrePeerAddrs: this.cadrePeerAddrs,
267
278
  resolveStrandAddrs: this.resolveStrandAddrs,
279
+ issueMembershipInvite: this.issueMembershipInvite,
268
280
  config: this.formationConfig
269
281
  });
270
282
  }
@@ -353,6 +365,9 @@ export class StrandSolicitationService {
353
365
  // Carry the host strand's membership key delivered over the protocol (closed-strand
354
366
  // provision-then-record). invitePrivateKey stays the initiator's generated signing key.
355
367
  memberPrivateKey: result.memberPrivateKey,
368
+ // The joiner's own single-use membership invitation into a closed host strand
369
+ // (already shape-checked by the manager); absent for open/unbound.
370
+ membershipInvite: result.membershipInvite,
356
371
  // The responder's live strand-network addresses, or `[]` when it had none to give.
357
372
  strandAddrs: result.strandAddrs
358
373
  };
@@ -30,7 +30,8 @@ import type { Libp2p, Connection } from '@libp2p/interface';
30
30
  import type { Multiaddr } from '@multiformats/multiaddr';
31
31
  import type { StrandInstance, WakeRequest, WakeAck } from './types.js';
32
32
  import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
33
- import { type ControlStream, writeFrame, withDeadline, exchangeFrame, readStreamToEnd } from './control-stream.js';
33
+ import { type ControlStream, writeFrame, exchangeFrame, readStreamToEnd } from './control-stream.js';
34
+ import { tryAddrsInTurn } from './peer-dial.js';
34
35
 
35
36
  const log = debug('sereus:cadre:strand-wake');
36
37
 
@@ -60,8 +61,10 @@ const DEFAULT_WAKE_TIMEOUT_MS = 10_000;
60
61
  *
61
62
  * This makes the TARGET PEER the unit rather than the address — the same
62
63
  * decision, for the same reason, as
63
- * `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` in `control-cohort.ts`, and the same
64
- * chosen number. 20 s is deliberately wider than one attempt timeout so a
64
+ * `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` in `peer-dial.ts`, though not the same
65
+ * number: that one is sized to fit several dead addresses ahead of a working one,
66
+ * while a wake target is expected to be awake and reachable on its first or second
67
+ * address. 20 s is deliberately wider than one attempt timeout so a
65
68
  * reachable peer whose signaling address is stale still gets a genuine try at
66
69
  * its direct one; a peer needing longer than that is not "asleep and reachable",
67
70
  * which is the only case a wake is for. The last attempt inside the budget gets
@@ -271,12 +274,6 @@ export interface DialWakeOptions {
271
274
  protocolId?: string;
272
275
  }
273
276
 
274
- /** One candidate's outcome, kept so the thrown error can name every attempt. */
275
- interface WakeDialFailure {
276
- addr: Multiaddr;
277
- error: Error;
278
- }
279
-
280
277
  /**
281
278
  * Sender side: dial a target's control-network address(es), send a
282
279
  * {@link WakeRequest} over `WAKE_PROTOCOL`, and return the peer's {@link WakeAck}.
@@ -289,16 +286,18 @@ interface WakeDialFailure {
289
286
  * dropped.
290
287
  *
291
288
  * Throws if no address is dialable — with an error naming EVERY candidate and
292
- * why it failed, not merely the last one. That distinction is not cosmetic:
293
- * with only the last candidate's message the failure that mattered (usually the
294
- * signaling address, tried first) is invisible outside a debug log, and the
295
- * surfaced message points at whichever address happened to be tried last.
289
+ * why it failed, not merely the last one ({@link tryAddrsInTurn}). That
290
+ * distinction is not cosmetic: with only the last candidate's message the
291
+ * failure that mattered (usually the signaling address, tried first) is
292
+ * invisible outside a debug log, and the surfaced message points at whichever
293
+ * address happened to be tried last.
296
294
  *
297
295
  * The candidate loop is deliberately explicit rather than one
298
296
  * `dialProtocol(addrs)` call. libp2p sorts any multi-address dial with
299
297
  * `defaultAddressSorter`, whose `circuitRelayAddressesLast` pass would demote
300
298
  * exactly the signaling address this ordering puts first — silently inverting
301
- * it, with no per-dial sorter override to opt out of.
299
+ * it, with no per-dial sorter override to opt out of. For the same reason this
300
+ * does not use `dialPeerAddrs`, which puts relayed addresses last.
302
301
  */
303
302
  export async function dialWake(
304
303
  node: Libp2p,
@@ -310,58 +309,14 @@ export async function dialWake(
310
309
  throw new Error('No dialable address for wake target');
311
310
  }
312
311
  const protocolId = options.protocolId ?? WAKE_PROTOCOL;
313
- const timeoutMs = options.timeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS;
314
- const budgetMs = options.budgetMs ?? DEFAULT_WAKE_DIAL_BUDGET_MS;
315
- const deadline = Date.now() + budgetMs;
316
-
317
- const failures: WakeDialFailure[] = [];
318
- for (const addr of addrs) {
319
- // Whatever is left of the whole-call budget caps this attempt, so the last
320
- // candidate inside the budget still gets a real (if shortened) try.
321
- const remaining = deadline - Date.now();
322
- if (remaining <= 0) {
323
- failures.push({ addr, error: new Error(`not tried — the ${budgetMs}ms wake dial budget was spent on earlier addresses`) });
324
- continue;
325
- }
326
- const attemptMs = Math.min(timeoutMs, remaining);
327
- try {
328
- // One deadline per attempt: its signal aborts the in-flight dialProtocol and
329
- // resets the live stream, so neither the connect nor the ack-read leaks.
330
- return await withDeadline(
331
- attemptMs,
332
- `Wake dial ${addr.toString()}`,
333
- (signal) => sendWake(node, addr, protocolId, request, attemptMs, signal),
334
- );
335
- } catch (err) {
336
- failures.push({ addr, error: err instanceof Error ? err : new Error(String(err)) });
337
- log('Wake dial to %s failed: %o', addr.toString(), err);
338
- }
339
- }
340
- throw wakeDialError(failures);
341
- }
342
-
343
- /**
344
- * Fold every candidate's failure into the one error {@link dialWake} throws.
345
- *
346
- * A single candidate throws its own error unchanged, so a one-address wake reads
347
- * exactly as it always has (and `rejects.toThrow(/timed out/)` still means what
348
- * it says). Several candidates produce one message naming each address and its
349
- * cause, with the FIRST failure as `cause` — the first candidate is the
350
- * signaling address, which is the one that matters when a relayed peer goes
351
- * unreachable.
352
- */
353
- function wakeDialError(failures: WakeDialFailure[]): Error {
354
- if (failures.length === 0) {
355
- return new Error('Wake dial failed');
356
- }
357
- if (failures.length === 1) {
358
- return failures[0].error;
359
- }
360
- const detail = failures.map((f) => `${f.addr.toString()} — ${f.error.message}`).join('; ');
361
- return new Error(
362
- `Wake dial failed for all ${failures.length} candidate addresses: ${detail}`,
363
- { cause: failures[0].error },
364
- );
312
+ const budget = {
313
+ perAddressMs: options.timeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS,
314
+ totalMs: options.budgetMs ?? DEFAULT_WAKE_DIAL_BUDGET_MS,
315
+ };
316
+ // The attempt's signal aborts the in-flight dialProtocol and resets the live
317
+ // stream, so neither the connect nor the ack-read leaks.
318
+ return await tryAddrsInTurn(addrs, budget, 'Wake dial', (addr, signal, attemptMs) =>
319
+ sendWake(node, addr, protocolId, request, attemptMs, signal));
365
320
  }
366
321
 
367
322
  /**
@@ -244,6 +244,12 @@ export class StrandWatcher {
244
244
  }
245
245
  }
246
246
  } catch (error) {
247
+ // NOTE: a machine cut off from its party before it ever received the `Strand` block
248
+ // fails this read `cohort-unreachable` on every poll and logs here (observed in
249
+ // `control-cohort-edge-carries-data`). Harmless: nothing was ever launched from a block
250
+ // this machine never held, and this catch keeps the known set. Do not answer it as
251
+ // empty the way `ControlDatabase.queryRevokedStamps` does for `Revocation` — an empty
252
+ // answer here runs the removed-strand loop above. Revisit if the log volume matters.
247
253
  log('Error polling strands: %o', error);
248
254
  }
249
255
  }
@@ -0,0 +1,28 @@
1
+ import debug from 'debug';
2
+
3
+ const timing = debug('sereus:cadre:timing');
4
+
5
+ /**
6
+ * Await one step of a longer operation between two `sereus:cadre:timing` lines:
7
+ * `[<scope>:<id>] <step>: start` before it, then `<step>: <n>ms` — or
8
+ * `<step>: failed after <n>ms` — once it settles. The start line is what matters on a
9
+ * device: a step that never settles leaves a start with no matching end, so a trace
10
+ * names the step that hung rather than only the last one that finished.
11
+ *
12
+ * The text is built here rather than passed as `%s`/`%d` arguments: `debug`'s browser
13
+ * build, which React Native bundles, leaves placeholders for the console to fill, and
14
+ * RN's console prints them unfilled, so a device trace would read `[%s:%s] %s: start`.
15
+ */
16
+ export async function timedStep<T>(scope: string, id: string, step: string, op: () => Promise<T>): Promise<T> {
17
+ const label = `[${scope}:${id}] ${step}`;
18
+ timing(`${label}: start`);
19
+ const t0 = performance.now();
20
+ try {
21
+ const result = await op();
22
+ timing(`${label}: ${Math.round(performance.now() - t0)}ms`);
23
+ return result;
24
+ } catch (error) {
25
+ timing(`${label}: failed after ${Math.round(performance.now() - t0)}ms`);
26
+ throw error;
27
+ }
28
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The timer seam shared by the strand bring-up loops that re-arm themselves one step at a
3
+ * time — the first-sync write gate's probe loop (`strand-first-sync-gate.ts`) and the
4
+ * membership reconciler's retry ladder (`strand-membership-reconciler.ts`). `setTimeout`
5
+ * rather than a repeating interval because the next step is armed only once the previous
6
+ * one settles, so a slow network read never stacks steps behind it, and because the delay
7
+ * can differ per step.
8
+ *
9
+ * Tests inject a hand-cranked clock; production omits it and gets real timers, `unref`'d so
10
+ * a loop that is only waiting never holds a Node process open (the call is optional —
11
+ * browsers and React Native return a plain handle with no `unref`).
12
+ */
13
+
14
+ /** Timer seam for a self-rescheduling loop; omit for real (unref'd) timeouts. */
15
+ export interface TimeoutScheduler {
16
+ setTimeout(fn: () => void, ms: number): unknown;
17
+ clearTimeout(handle: unknown): void;
18
+ }
19
+
20
+ /** Real timers, `unref`'d where the runtime supports it. */
21
+ export const defaultTimeoutScheduler: TimeoutScheduler = {
22
+ setTimeout: (fn, ms) => {
23
+ const handle = setTimeout(fn, ms);
24
+ (handle as { unref?: () => void }).unref?.();
25
+ return handle;
26
+ },
27
+ clearTimeout: (handle) => clearTimeout(handle as Parameters<typeof clearTimeout>[0])
28
+ };