@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.
- package/README.md +20 -6
- package/dist/bootstrap-peer-store-file.js +2 -2
- package/dist/bootstrap-peer-store.d.ts +32 -12
- package/dist/bootstrap-peer-store.js +41 -15
- package/dist/bootstrap-peer-store.js.map +1 -1
- package/dist/cadre-node.d.ts +558 -80
- package/dist/cadre-node.js +1060 -170
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +2 -2
- package/dist/control-authorization.js +1 -0
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +13 -26
- package/dist/control-cohort.js +0 -26
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +299 -44
- package/dist/control-database.js +469 -69
- package/dist/control-database.js.map +1 -1
- package/dist/control-read-retry.d.ts +40 -11
- package/dist/control-read-retry.js +48 -15
- package/dist/control-read-retry.js.map +1 -1
- package/dist/control-retry.d.ts +58 -3
- package/dist/control-retry.js +58 -3
- package/dist/control-retry.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +103 -19
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +80 -11
- package/dist/control-write-retry.js +176 -29
- package/dist/control-write-retry.js.map +1 -1
- package/dist/enrolled-machine-store.js +2 -2
- package/dist/index.d.ts +14 -7
- package/dist/index.js +33 -6
- package/dist/index.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +15 -7
- package/dist/node-local-snapshot.js +23 -8
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/peer-dial.d.ts +143 -0
- package/dist/peer-dial.js +176 -0
- package/dist/peer-dial.js.map +1 -0
- package/dist/relay-addrs.d.ts +90 -52
- package/dist/relay-addrs.js +91 -59
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +71 -3
- package/dist/relay-reservation.js +116 -8
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +30 -1
- package/dist/seed-bootstrap.js +62 -17
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/storage-scope.d.ts +62 -0
- package/dist/storage-scope.js +75 -0
- package/dist/storage-scope.js.map +1 -0
- package/dist/strand-database.d.ts +34 -16
- package/dist/strand-database.js +38 -24
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +152 -0
- package/dist/strand-first-sync-gate.js +208 -0
- package/dist/strand-first-sync-gate.js.map +1 -0
- package/dist/strand-formation-manager.d.ts +87 -5
- package/dist/strand-formation-manager.js +109 -5
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +21 -1
- package/dist/strand-formation-protocol.js +30 -0
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +243 -6
- package/dist/strand-instance-manager.js +470 -37
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +20 -12
- package/dist/strand-member-key.js +20 -12
- package/dist/strand-member-key.js.map +1 -1
- package/dist/strand-member-registry.js +3 -0
- package/dist/strand-member-registry.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +361 -0
- package/dist/strand-membership-reconciler.js +548 -0
- package/dist/strand-membership-reconciler.js.map +1 -0
- package/dist/strand-membership-writer.d.ts +131 -11
- package/dist/strand-membership-writer.js +396 -134
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +46 -14
- package/dist/strand-network-config.js +79 -23
- package/dist/strand-network-config.js.map +1 -1
- package/dist/strand-solicitation.d.ts +10 -1
- package/dist/strand-solicitation.js +5 -0
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +11 -7
- package/dist/strand-wake-protocol.js +20 -52
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/strand-watcher.js +6 -0
- package/dist/strand-watcher.js.map +1 -1
- package/dist/timed-step.d.ts +12 -0
- package/dist/timed-step.js +28 -0
- package/dist/timed-step.js.map +1 -0
- package/dist/timeout-scheduler.d.ts +19 -0
- package/dist/timeout-scheduler.js +22 -0
- package/dist/timeout-scheduler.js.map +1 -0
- package/dist/types.d.ts +262 -30
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/bootstrap-peer-store-file.ts +2 -2
- package/src/bootstrap-peer-store.ts +69 -27
- package/src/cadre-node.ts +7301 -6353
- package/src/control-authorization.ts +2 -1
- package/src/control-cohort.ts +122 -130
- package/src/control-database.ts +537 -69
- package/src/control-read-retry.ts +202 -167
- package/src/control-retry.ts +297 -190
- package/src/control-schema.ts +103 -19
- package/src/control-write-retry.ts +533 -383
- package/src/enrolled-machine-store.ts +2 -2
- package/src/index.ts +83 -6
- package/src/node-local-snapshot.ts +25 -8
- package/src/peer-dial.ts +215 -0
- package/src/relay-addrs.ts +458 -436
- package/src/relay-reservation.ts +951 -828
- package/src/seed-bootstrap.ts +76 -18
- package/src/storage-scope.ts +78 -0
- package/src/strand-database.ts +50 -27
- package/src/strand-first-sync-gate.ts +258 -0
- package/src/strand-formation-manager.ts +140 -5
- package/src/strand-formation-protocol.ts +43 -1
- package/src/strand-instance-manager.ts +577 -37
- package/src/strand-member-key.ts +20 -12
- package/src/strand-member-registry.ts +3 -0
- package/src/strand-membership-reconciler.ts +680 -0
- package/src/strand-membership-writer.ts +527 -189
- package/src/strand-network-config.ts +102 -24
- package/src/strand-solicitation.ts +16 -1
- package/src/strand-wake-protocol.ts +21 -66
- package/src/strand-watcher.ts +6 -0
- package/src/timed-step.ts +28 -0
- package/src/timeout-scheduler.ts +28 -0
- 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
|
|
45
|
-
*
|
|
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
|
|
108
|
-
* so chaining writes keeps them ordered and the last landed
|
|
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:
|
|
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
|
|
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
|
|
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);
|
package/src/peer-dial.ts
ADDED
|
@@ -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
|
+
}
|