@serfab/cadre-core 1.6.0 → 1.8.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 (179) hide show
  1. package/README.md +8 -6
  2. package/dist/cadre-node.d.ts +548 -137
  3. package/dist/cadre-node.js +1208 -383
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-authorization.d.ts +2 -2
  6. package/dist/control-authorization.js +1 -0
  7. package/dist/control-authorization.js.map +1 -1
  8. package/dist/control-cohort.d.ts +6 -0
  9. package/dist/control-cohort.js +6 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +46 -8
  12. package/dist/control-database.js +97 -18
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +37 -14
  15. package/dist/control-formation-recorder.js +60 -10
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +13 -12
  18. package/dist/control-read-retry.js +14 -12
  19. package/dist/control-read-retry.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +67 -5
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +9 -1
  24. package/dist/control-stream.js +24 -1
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +12 -2
  27. package/dist/control-write-retry.js +13 -2
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +11 -8
  30. package/dist/delegate-admission.js +19 -11
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/diagnostics/connection-path.d.ts +0 -7
  33. package/dist/diagnostics/connection-path.js +0 -7
  34. package/dist/diagnostics/connection-path.js.map +1 -1
  35. package/dist/formation-approval.d.ts +7 -0
  36. package/dist/formation-approval.js +9 -3
  37. package/dist/formation-approval.js.map +1 -1
  38. package/dist/fs-atomic.d.ts +11 -2
  39. package/dist/fs-atomic.js +13 -3
  40. package/dist/fs-atomic.js.map +1 -1
  41. package/dist/hibernation-manager.d.ts +111 -8
  42. package/dist/hibernation-manager.js +234 -49
  43. package/dist/hibernation-manager.js.map +1 -1
  44. package/dist/index.d.ts +13 -6
  45. package/dist/index.js +27 -5
  46. package/dist/index.js.map +1 -1
  47. package/dist/joined-strand-store.d.ts +177 -0
  48. package/dist/joined-strand-store.js +431 -0
  49. package/dist/joined-strand-store.js.map +1 -0
  50. package/dist/key-store-file.js +14 -5
  51. package/dist/key-store-file.js.map +1 -1
  52. package/dist/key-store.d.ts +6 -0
  53. package/dist/key-store.js.map +1 -1
  54. package/dist/link-budget.d.ts +367 -69
  55. package/dist/link-budget.js +375 -72
  56. package/dist/link-budget.js.map +1 -1
  57. package/dist/membership-connection-gater.d.ts +30 -43
  58. package/dist/membership-connection-gater.js +31 -42
  59. package/dist/membership-connection-gater.js.map +1 -1
  60. package/dist/node-local-snapshot.d.ts +5 -4
  61. package/dist/node-local-snapshot.js +6 -3
  62. package/dist/node-local-snapshot.js.map +1 -1
  63. package/dist/peer-dial.d.ts +24 -4
  64. package/dist/peer-dial.js +74 -5
  65. package/dist/peer-dial.js.map +1 -1
  66. package/dist/peer-join-backfill.d.ts +35 -15
  67. package/dist/peer-join-backfill.js +115 -55
  68. package/dist/peer-join-backfill.js.map +1 -1
  69. package/dist/push-fanout.d.ts +6 -0
  70. package/dist/push-fanout.js +6 -0
  71. package/dist/push-fanout.js.map +1 -1
  72. package/dist/relay-reservation.d.ts +3 -1
  73. package/dist/relay-reservation.js +3 -1
  74. package/dist/relay-reservation.js.map +1 -1
  75. package/dist/relay-server.d.ts +79 -0
  76. package/dist/relay-server.js +74 -0
  77. package/dist/relay-server.js.map +1 -0
  78. package/dist/seed-bootstrap.d.ts +57 -4
  79. package/dist/seed-bootstrap.js +107 -83
  80. package/dist/seed-bootstrap.js.map +1 -1
  81. package/dist/storage-scope.d.ts +33 -17
  82. package/dist/storage-scope.js +44 -23
  83. package/dist/storage-scope.js.map +1 -1
  84. package/dist/strand-addr-protocol.d.ts +58 -21
  85. package/dist/strand-addr-protocol.js +133 -85
  86. package/dist/strand-addr-protocol.js.map +1 -1
  87. package/dist/strand-database.d.ts +20 -2
  88. package/dist/strand-database.js +42 -5
  89. package/dist/strand-database.js.map +1 -1
  90. package/dist/strand-first-sync-gate.d.ts +59 -21
  91. package/dist/strand-first-sync-gate.js +58 -19
  92. package/dist/strand-first-sync-gate.js.map +1 -1
  93. package/dist/strand-formation-deadlines.d.ts +151 -0
  94. package/dist/strand-formation-deadlines.js +171 -0
  95. package/dist/strand-formation-deadlines.js.map +1 -0
  96. package/dist/strand-formation-manager.d.ts +63 -30
  97. package/dist/strand-formation-manager.js +67 -41
  98. package/dist/strand-formation-manager.js.map +1 -1
  99. package/dist/strand-formation-protocol.d.ts +45 -35
  100. package/dist/strand-formation-protocol.js +59 -122
  101. package/dist/strand-formation-protocol.js.map +1 -1
  102. package/dist/strand-instance-manager.d.ts +152 -6
  103. package/dist/strand-instance-manager.js +331 -42
  104. package/dist/strand-instance-manager.js.map +1 -1
  105. package/dist/strand-membership-writer.d.ts +6 -0
  106. package/dist/strand-membership-writer.js +11 -0
  107. package/dist/strand-membership-writer.js.map +1 -1
  108. package/dist/strand-peer-book-file.d.ts +12 -0
  109. package/dist/strand-peer-book-file.js +37 -0
  110. package/dist/strand-peer-book-file.js.map +1 -0
  111. package/dist/strand-peer-book-protocol.d.ts +168 -0
  112. package/dist/strand-peer-book-protocol.js +356 -0
  113. package/dist/strand-peer-book-protocol.js.map +1 -0
  114. package/dist/strand-peer-book-swap.d.ts +104 -0
  115. package/dist/strand-peer-book-swap.js +284 -0
  116. package/dist/strand-peer-book-swap.js.map +1 -0
  117. package/dist/strand-peer-book.d.ts +159 -0
  118. package/dist/strand-peer-book.js +361 -0
  119. package/dist/strand-peer-book.js.map +1 -0
  120. package/dist/strand-peer-observer.d.ts +88 -0
  121. package/dist/strand-peer-observer.js +180 -0
  122. package/dist/strand-peer-observer.js.map +1 -0
  123. package/dist/strand-revocation-enforcer.js +2 -1
  124. package/dist/strand-revocation-enforcer.js.map +1 -1
  125. package/dist/strand-solicitation.d.ts +36 -20
  126. package/dist/strand-solicitation.js +4 -4
  127. package/dist/strand-solicitation.js.map +1 -1
  128. package/dist/strand-wake-protocol.d.ts +80 -23
  129. package/dist/strand-wake-protocol.js +89 -64
  130. package/dist/strand-wake-protocol.js.map +1 -1
  131. package/dist/types.d.ts +181 -33
  132. package/dist/types.js +4 -3
  133. package/dist/types.js.map +1 -1
  134. package/package.json +11 -7
  135. package/src/cadre-node.ts +1316 -398
  136. package/src/control-authorization.ts +2 -1
  137. package/src/control-cohort.ts +6 -0
  138. package/src/control-database.ts +122 -18
  139. package/src/control-formation-recorder.ts +354 -305
  140. package/src/control-read-retry.ts +14 -12
  141. package/src/control-schema.ts +67 -5
  142. package/src/control-stream.ts +25 -1
  143. package/src/control-write-retry.ts +13 -2
  144. package/src/delegate-admission.ts +23 -13
  145. package/src/diagnostics/connection-path.ts +0 -7
  146. package/src/formation-approval.ts +610 -604
  147. package/src/fs-atomic.ts +14 -4
  148. package/src/hibernation-manager.ts +638 -437
  149. package/src/index.ts +91 -6
  150. package/src/joined-strand-store.ts +498 -0
  151. package/src/key-store-file.ts +14 -5
  152. package/src/key-store.ts +6 -0
  153. package/src/link-budget.ts +393 -74
  154. package/src/membership-connection-gater.ts +31 -45
  155. package/src/node-local-snapshot.ts +293 -289
  156. package/src/peer-dial.ts +81 -7
  157. package/src/peer-join-backfill.ts +120 -57
  158. package/src/push-fanout.ts +6 -0
  159. package/src/relay-reservation.ts +3 -1
  160. package/src/relay-server.ts +98 -0
  161. package/src/seed-bootstrap.ts +133 -91
  162. package/src/storage-scope.ts +44 -23
  163. package/src/strand-addr-protocol.ts +547 -458
  164. package/src/strand-database.ts +287 -242
  165. package/src/strand-first-sync-gate.ts +58 -19
  166. package/src/strand-formation-deadlines.ts +227 -0
  167. package/src/strand-formation-manager.ts +700 -647
  168. package/src/strand-formation-protocol.ts +103 -150
  169. package/src/strand-instance-manager.ts +387 -45
  170. package/src/strand-membership-writer.ts +12 -0
  171. package/src/strand-peer-book-file.ts +39 -0
  172. package/src/strand-peer-book-protocol.ts +466 -0
  173. package/src/strand-peer-book-swap.ts +338 -0
  174. package/src/strand-peer-book.ts +475 -0
  175. package/src/strand-peer-observer.ts +235 -0
  176. package/src/strand-revocation-enforcer.ts +2 -1
  177. package/src/strand-solicitation.ts +519 -500
  178. package/src/strand-wake-protocol.ts +401 -355
  179. package/src/types.ts +185 -33
@@ -19,16 +19,22 @@
19
19
  */
20
20
 
21
21
  import debug from 'debug';
22
- import { multiaddr } from '@multiformats/multiaddr';
22
+ import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
23
23
  import type { Libp2p } from '@libp2p/interface';
24
24
  import type { StrandFormationDisclosure, StrandMembershipInvite } from './types.js';
25
- import { type ControlStream, writeFrame, withTimeout } from './control-stream.js';
25
+ import { type ControlStream, writeFrame, withDeadline, withTimeout } from './control-stream.js';
26
26
  import { canonicalJson } from './canonical-json.js';
27
27
  import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
28
28
  import { verifyFormationConsent } from './peer-authorization.js';
29
29
  // seed-bootstrap imports neither this protocol nor the manager/solicitation layers,
30
30
  // so this import introduces no cycle.
31
31
  import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
32
+ import {
33
+ formationDeadlines,
34
+ resolveProvisionTimeoutMs,
35
+ responderClampReserveMs,
36
+ splitProvisionBudget
37
+ } from './strand-formation-deadlines.js';
32
38
 
33
39
  const log = debug('sereus:cadre:formation-proto');
34
40
 
@@ -47,66 +53,16 @@ export const INVALID_TOKEN_REASON = 'Invalid token';
47
53
  /** Maximum formation message size (1MB). */
48
54
  const MAX_FORMATION_MSG_SIZE = 1024 * 1024;
49
55
 
50
- /** Default whole-session timeout (ms). */
51
- const DEFAULT_SESSION_TIMEOUT_MS = 30_000;
52
- /** Default per-step (single read/write) timeout (ms). */
53
- const DEFAULT_STEP_TIMEOUT_MS = 5_000;
54
- /**
55
- * Default provisioning budget (ms) — distinct from {@link DEFAULT_STEP_TIMEOUT_MS} because
56
- * `provisionStrand` is real work (DB writes, and an outbound approval-hook HTTP call when
57
- * the invite carries a `ValidationUrl`), not a bare wire read/write.
58
- *
59
- * Ordering is deliberate — each layer must be able to fail and report before the layer above
60
- * it gives up:
61
- *
62
- * approval hook (10 s, `formation-approval.ts`)
63
- * < responder provisioning (12 s)
64
- * < initiator await-response (15 s)
65
- * < session (30 s)
66
- *
67
- * The 3 s margin between responder provisioning and initiator await-response is the wire
68
- * latency budget for the result frame to travel back.
69
- *
70
- * The last {@link PROVISION_SETTLE_GRACE_MS} of this budget is a settle grace, so the default
71
- * WORK budget (12 s − 2 s = 10 s) EQUALS the hook's own 10 s: a dead hook races 'Formation
72
- * approval unavailable, retry' against 'Formation provisioning timed out'. Both are retryable
73
- * and both leave the invite unspent, so the race is benign.
74
- */
75
- const DEFAULT_PROVISION_TIMEOUT_MS = 12_000;
76
- /**
77
- * Settle grace (ms), carved OUT of `provisionTimeoutMs` — never added on top, so the budget
78
- * ladder above is untouched. The listener aborts the provisioning hook when the work budget
79
- * ({@link splitProvisionBudget}) expires, then waits up to this grace for the work to settle
80
- * anyway: once the `FormationUsage` insert has been issued nothing can un-spend it (the table
81
- * is append-only), so a provisioning that lands late is ADOPTED and reported as a real
82
- * approval rather than lying "timed out" over a spent invite. A hook that observes the
83
- * abort before writing leaves the invite unspent.
84
- */
85
- const PROVISION_SETTLE_GRACE_MS = 2_000;
86
- /**
87
- * Wire-latency margin (ms) added to the responder's provisioning budget to get the
88
- * initiator's await-response budget — the travel time for the result frame to come back
89
- * after the responder finishes (see {@link DEFAULT_PROVISION_TIMEOUT_MS}).
90
- * `StrandFormationManager` reuses this constant so a CONFIGURED budget preserves the same
91
- * margin the defaults do, instead of applying the same number to both sides and racing the
92
- * initiator's own timeout against the responder's clean rejection reply. The responder's
93
- * clamp ({@link provisionCeilingMs}) holds the same margin back, so the ordering survives a
94
- * budget large enough that BOTH roles get clamped.
95
- *
96
- * NOTE: 3 s is a fixed assumption about how long the result frame takes to travel back. If
97
- * formation ever runs over paths where that frame can take longer (a slow circuit relay, a
98
- * congested mobile link), the margin is too thin and the initiator's timeout races the
99
- * responder's reply again — raise it, or derive it from an observed round-trip.
100
- */
101
- export const PROVISION_RESPONSE_TRAVEL_MARGIN_MS = 3_000;
102
- /** Default initiator await-response budget (ms); see {@link DEFAULT_PROVISION_TIMEOUT_MS}. */
103
- const DEFAULT_INITIATOR_PROVISION_TIMEOUT_MS = DEFAULT_PROVISION_TIMEOUT_MS + PROVISION_RESPONSE_TRAVEL_MARGIN_MS;
104
56
  /**
105
57
  * Default cap on concurrent inbound formation sessions.
106
- * NOTE: a longer provisioning budget holds a session's slot longer, so a slow hook can
107
- * exhaust this cap and make the listener reject fresh joiners with 'Too many concurrent
108
- * formation sessions'. Fine at the current default; revisit if `provisionTimeoutMs` is
109
- * raised well above its default or this cap is lowered.
58
+ *
59
+ * NOTE: a session holds its slot for its whole provisioning budget, 171 s at the default
60
+ * declared link ({@link formationDeadlines}), so a slow hook or commit holds slots for
61
+ * minutes. Only a caller holding a valid token AND a valid consent signature reaches
62
+ * provisioning — a stranger is cut at the contact wait (7 s at the default) or at the first
63
+ * validation read — so the exposure is one invitee opening many sessions with one token. If
64
+ * invitation tokens are ever published where untrusted parties can read them, cap in-flight
65
+ * sessions per token rather than lowering this.
110
66
  */
111
67
  const DEFAULT_MAX_CONCURRENT_SESSIONS = 100;
112
68
 
@@ -380,62 +336,6 @@ export function isValidResponderCreatesResult(response: FormationResultMessage):
380
336
  return true;
381
337
  }
382
338
 
383
- /**
384
- * Ceiling for a resolved provisioning budget.
385
- *
386
- * The session budget also has to cover the wire step that PRECEDES provisioning (the
387
- * responder's contact read, the initiator's dial-connect), so a whole step is held back
388
- * rather than triggering only on a literal overrun.
389
- *
390
- * `reserveMs` is the EXTRA room only the responder holds back, so its own budget still
391
- * lands strictly before the initiator's larger await-response budget even when both are
392
- * clamped — without it, any configured budget at or above `sessionTimeoutMs -
393
- * stepTimeoutMs` clamps both roles onto the same number and the responder's clean
394
- * rejection races the initiator's timeout again. Capped at half the remaining room (like
395
- * {@link splitProvisionBudget}) so a small session config still spends most of it working.
396
- */
397
- function provisionCeilingMs(sessionTimeoutMs: number, stepTimeoutMs: number, reserveMs: number): number {
398
- const roomMs = sessionTimeoutMs - stepTimeoutMs;
399
- return Math.max(1, roomMs - Math.min(reserveMs, Math.floor(roomMs / 2)));
400
- }
401
-
402
- /**
403
- * Resolve a caller-supplied provisioning budget: `0`/negative means "unset" (use
404
- * `defaultMs`). If the result would let provisioning outlive the session — no result
405
- * frame is ever sent, exactly the failure this budget exists to prevent — clamp it to
406
- * {@link provisionCeilingMs} and log a warning.
407
- */
408
- function resolveProvisionTimeoutMs(
409
- configured: number | undefined,
410
- defaultMs: number,
411
- sessionTimeoutMs: number,
412
- stepTimeoutMs: number,
413
- role: string,
414
- reserveMs = 0
415
- ): number {
416
- const requested = configured && configured > 0 ? configured : defaultMs;
417
- const ceilingMs = provisionCeilingMs(sessionTimeoutMs, stepTimeoutMs, reserveMs);
418
- if (requested >= ceilingMs) {
419
- log(
420
- '%s provisionTimeoutMs %dms leaves no room under sessionTimeoutMs %dms (step %dms, reserve %dms); clamping to %dms',
421
- role, requested, sessionTimeoutMs, stepTimeoutMs, reserveMs, ceilingMs
422
- );
423
- return ceilingMs;
424
- }
425
- return requested;
426
- }
427
-
428
- /**
429
- * Split a resolved provisioning budget into the WORK budget and the trailing settle grace.
430
- *
431
- * The grace is carved OUT of the budget (see {@link PROVISION_SETTLE_GRACE_MS}), and capped at
432
- * half of it so a small configured budget still spends at least half its time doing work.
433
- */
434
- function splitProvisionBudget(provisionTimeoutMs: number): { workMs: number; graceMs: number } {
435
- const graceMs = Math.min(PROVISION_SETTLE_GRACE_MS, Math.floor(provisionTimeoutMs / 2));
436
- return { workMs: provisionTimeoutMs - graceMs, graceMs };
437
- }
438
-
439
339
  // ── Responder (listener) ─────────────────────────────────────────────────────
440
340
 
441
341
  export interface FormationListenerOptions {
@@ -471,14 +371,22 @@ export interface FormationListenerOptions {
471
371
  * per-strand address lookup.
472
372
  */
473
373
  resolveStrandAddrs?(strandId: string): string[];
374
+ /**
375
+ * This machine's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
376
+ * every deadline below takes its default ({@link formationDeadlines}). The default
377
+ * declaration when omitted.
378
+ */
379
+ linkRoundTripMs?: number;
380
+ /** Whole-session budget; default `sessionMs` of the derived ladder. */
474
381
  sessionTimeoutMs?: number;
382
+ /** Budget for the contact-frame read; default `awaitContactMs` of the derived ladder. */
475
383
  stepTimeoutMs?: number;
476
384
  /**
477
- * Budget for the `provisionStrand` hook call — distinct from `stepTimeoutMs` because
478
- * provisioning is real work, not a bare wire read/write. Default 12 s; `0`/unset uses
479
- * the default; a value that would outlive the session is clamped ({@link
480
- * provisionCeilingMs}). See {@link DEFAULT_PROVISION_TIMEOUT_MS} for the full ordering
481
- * rationale.
385
+ * Budget for the `provisionStrand` hook call, work plus settle grace — distinct from
386
+ * `stepTimeoutMs` because provisioning is real work, not a bare wire read. Default
387
+ * `provisionWorkMs + provisionGraceMs` of the derived ladder (171 s at the default
388
+ * declaration); `0`/unset uses the default; a value that would outlive the session is
389
+ * clamped ({@link resolveProvisionTimeoutMs}). See {@link formationDeadlines} for the ordering.
482
390
  */
483
391
  provisionTimeoutMs?: number;
484
392
  maxConcurrentSessions?: number;
@@ -493,7 +401,7 @@ export interface FormationListenerOptions {
493
401
  export class FormationListener {
494
402
  private readonly options: FormationListenerOptions;
495
403
  private readonly sessionTimeoutMs: number;
496
- private readonly stepTimeoutMs: number;
404
+ private readonly awaitContactMs: number;
497
405
  private readonly provisionWorkMs: number;
498
406
  private readonly provisionGraceMs: number;
499
407
  private readonly maxConcurrentSessions: number;
@@ -503,13 +411,14 @@ export class FormationListener {
503
411
 
504
412
  constructor(options: FormationListenerOptions) {
505
413
  this.options = options;
506
- this.sessionTimeoutMs = options.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
507
- this.stepTimeoutMs = options.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS;
414
+ const deadlines = formationDeadlines(options.linkRoundTripMs);
415
+ this.sessionTimeoutMs = options.sessionTimeoutMs ?? deadlines.sessionMs;
416
+ this.awaitContactMs = options.stepTimeoutMs ?? deadlines.awaitContactMs;
508
417
  const split = splitProvisionBudget(resolveProvisionTimeoutMs(
509
- options.provisionTimeoutMs, DEFAULT_PROVISION_TIMEOUT_MS,
510
- this.sessionTimeoutMs, this.stepTimeoutMs, 'FormationListener',
511
- PROVISION_RESPONSE_TRAVEL_MARGIN_MS
512
- ));
418
+ options.provisionTimeoutMs, deadlines.provisionWorkMs + deadlines.provisionGraceMs,
419
+ this.sessionTimeoutMs, this.awaitContactMs + deadlines.validationMs, 'FormationListener',
420
+ responderClampReserveMs(deadlines, this.awaitContactMs)
421
+ ), deadlines.provisionGraceMs);
513
422
  this.provisionWorkMs = split.workMs;
514
423
  this.provisionGraceMs = split.graceMs;
515
424
  this.maxConcurrentSessions = options.maxConcurrentSessions ?? DEFAULT_MAX_CONCURRENT_SESSIONS;
@@ -520,21 +429,22 @@ export class FormationListener {
520
429
  return this.activeSessions;
521
430
  }
522
431
 
523
- register(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): void {
432
+ /** Register the formation handler on `node`. Rejects when libp2p refuses the registration. */
433
+ async register(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): Promise<void> {
524
434
  if (this.registered.has(node)) {
525
435
  log('node already registered');
526
436
  return;
527
437
  }
528
- void node.handle(protocolId, async (rawStream: unknown, _connection: unknown) => {
438
+ await node.handle(protocolId, async (rawStream: unknown, _connection: unknown) => {
529
439
  await this.handleStream(rawStream as ControlStream);
530
440
  });
531
441
  this.registered.add(node);
532
442
  log('formation listener registered (%s)', protocolId);
533
443
  }
534
444
 
535
- unregister(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): void {
445
+ async unregister(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): Promise<void> {
536
446
  if (!this.registered.has(node)) return;
537
- void node.unhandle(protocolId);
447
+ await node.unhandle(protocolId);
538
448
  this.registered.delete(node);
539
449
  log('formation listener unregistered (%s)', protocolId);
540
450
  }
@@ -559,8 +469,8 @@ export class FormationListener {
559
469
  }
560
470
 
561
471
  /**
562
- * Run the provisioning hook under its WORK budget — the configured provisioning budget minus
563
- * the trailing settle grace ({@link splitProvisionBudget}, {@link PROVISION_SETTLE_GRACE_MS}).
472
+ * Run the provisioning hook under its WORK budget — the provisioning budget minus the
473
+ * trailing settle grace ({@link splitProvisionBudget}).
564
474
  *
565
475
  * When the work budget expires the hook's signal is aborted, then
566
476
  * {@link settleWithinGrace} waits out the grace for the aborted work to settle anyway.
@@ -600,11 +510,16 @@ export class FormationListener {
600
510
  * inside the grace its outcome is ADOPTED — the joiner is told the truth (approved, or the
601
511
  * hook's own rejection reason) rather than "timed out" over a spent invite.
602
512
  *
603
- * NOTE: one window stays open — a work budget that expires after the `FormationUsage`
604
- * insert was issued AND a commit that outlasts the grace. The joiner is told 'timed out'
605
- * while its one-time invite is in fact spent, and since every retry mints a fresh keypair
606
- * no recovery path can match it. Nothing can un-spend an append-only row, so the late-settle
607
- * logging below is the observability for it; widen the grace only if it is seen in the wild.
513
+ * The grace is derived to contain one seat read plus one commit at the declared link
514
+ * ({@link formationDeadlines}), which is exactly what runs after the insert attempt passes
515
+ * its abort check, so at that link the late outcome lands inside it.
516
+ *
517
+ * NOTE: a commit that outlasts even the derived grace (a link slower than declared, or a
518
+ * configured `provisionTimeoutMs` whose half-cap shrank the grace) still tells the joiner
519
+ * 'timed out' while its one-time invite is in fact spent, and since every retry mints a
520
+ * fresh keypair no recovery path can match it. Nothing can un-spend an append-only row, so
521
+ * the late-settle logging below is the observability for it; if it is seen in the wild,
522
+ * raise the declared link rather than this grace.
608
523
  */
609
524
  private async settleWithinGrace(
610
525
  id: number,
@@ -706,7 +621,7 @@ export class FormationListener {
706
621
 
707
622
  try {
708
623
  const reader = new FrameReader(stream);
709
- const contact = await withTimeout(this.stepTimeoutMs, `Formation await-contact#${id}`, () => reader.read<FormationContactMessage>());
624
+ const contact = await withTimeout(this.awaitContactMs, `Formation await-contact#${id}`, () => reader.read<FormationContactMessage>());
710
625
  log('formation session #%d contact: token=%s party=%s', id, contact.token, contact.partyId);
711
626
 
712
627
  if (!this.isJoinerConsentValid(id, contact)) {
@@ -777,12 +692,24 @@ export interface FormationDialOptions {
777
692
  responderAddrs: string[];
778
693
  /** Validate the responder's result; a false return aborts the formation. */
779
694
  validateResponse(response: FormationResultMessage): Promise<boolean>;
695
+ /**
696
+ * This machine's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
697
+ * every deadline below takes its default ({@link formationDeadlines}). The default
698
+ * declaration when omitted.
699
+ */
700
+ linkRoundTripMs?: number;
701
+ /** Whole-session budget; default `sessionMs` of the derived ladder. */
780
702
  sessionTimeoutMs?: number;
781
- stepTimeoutMs?: number;
782
703
  /**
783
- * Budget for the `await-response` read only (the responder's provisioning + reply travel
784
- * time) — `dial-connect` keeps using `stepTimeoutMs`. Default 15 s; `0`/unset uses the
785
- * default. See {@link DEFAULT_PROVISION_TIMEOUT_MS} for the full ordering rationale.
704
+ * Budget for `dial-connect`: opening the connection, through a relay if need be, and
705
+ * negotiating the formation protocol. Default `dialMs` of the derived ladder.
706
+ */
707
+ dialTimeoutMs?: number;
708
+ /**
709
+ * Budget for the `await-response` read only (the responder's provisioning plus the reply's
710
+ * travel time). Default `initiatorAwaitResponseMs` of the derived ladder; `0`/unset uses
711
+ * the default; a value that would outlive the session is clamped. See
712
+ * {@link formationDeadlines} for the ordering.
786
713
  */
787
714
  provisionTimeoutMs?: number;
788
715
  protocolId?: string;
@@ -806,6 +733,32 @@ export interface FormationDialResult {
806
733
  strandAddrs: string[];
807
734
  }
808
735
 
736
+ /**
737
+ * Open the formation stream under the dial deadline, cancelling the dial when it expires.
738
+ *
739
+ * The deadline's signal goes into `dialProtocol`, so an expired dial is abandoned by libp2p
740
+ * rather than left running. A dial that settles in the same tick the deadline fires loses
741
+ * the race to the timeout rejection and would leave its stream open with nobody to close
742
+ * it, so a stream that arrives after the signal aborted is reset here — the same treatment
743
+ * `exchangeFrame` (`control-stream.ts`) gives a stream dialed past its deadline. A late
744
+ * rejection is the abort itself, or reaches the caller through the awaited copy, so the
745
+ * handler below only stops it being reported as unhandled.
746
+ */
747
+ function openFormationStream(node: Libp2p, addr: Multiaddr, protocolId: string, dialTimeoutMs: number): Promise<ControlStream> {
748
+ return withDeadline(dialTimeoutMs, 'Formation dial-connect', (signal) => {
749
+ const pending = node.dialProtocol(addr, protocolId, { signal });
750
+ void pending.then(
751
+ (stream) => {
752
+ if (signal.aborted) {
753
+ (stream as unknown as ControlStream).abort(new Error('Formation dial-connect resolved after its deadline'));
754
+ }
755
+ },
756
+ () => { /* surfaced through the awaited copy, or the abort itself */ }
757
+ );
758
+ return pending as unknown as Promise<ControlStream>;
759
+ });
760
+ }
761
+
809
762
  /**
810
763
  * Initiator side of the native formation protocol. Dials the responder, sends the
811
764
  * contact (carrying the real disclosure/token/cadre), validates the responder's
@@ -817,17 +770,17 @@ export async function dialFormation(node: Libp2p, options: FormationDialOptions)
817
770
  throw new Error('No responder addresses available for formation');
818
771
  }
819
772
  const protocolId = options.protocolId ?? FORMATION_PROTOCOL;
820
- const sessionTimeoutMs = options.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
821
- const stepTimeoutMs = options.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS;
773
+ const deadlines = formationDeadlines(options.linkRoundTripMs);
774
+ const sessionTimeoutMs = options.sessionTimeoutMs ?? deadlines.sessionMs;
775
+ const dialTimeoutMs = options.dialTimeoutMs ?? deadlines.dialMs;
822
776
  const provisionTimeoutMs = resolveProvisionTimeoutMs(
823
- options.provisionTimeoutMs, DEFAULT_INITIATOR_PROVISION_TIMEOUT_MS,
824
- sessionTimeoutMs, stepTimeoutMs, 'dialFormation'
777
+ options.provisionTimeoutMs, deadlines.initiatorAwaitResponseMs,
778
+ sessionTimeoutMs, dialTimeoutMs, 'dialFormation'
825
779
  );
826
780
  const addr = multiaddr(options.responderAddrs[0]);
827
781
 
828
782
  return withTimeout(sessionTimeoutMs, 'Formation dial', async () => {
829
- const rawStream = await withTimeout(stepTimeoutMs, 'Formation dial-connect', async () => node.dialProtocol(addr, protocolId));
830
- const stream = rawStream as unknown as ControlStream;
783
+ const stream = await openFormationStream(node, addr, protocolId, dialTimeoutMs);
831
784
  try {
832
785
  const reader = new FrameReader(stream);
833
786
  writeFrame(stream, options.contact);