@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
@@ -1,355 +1,401 @@
1
- /**
2
- * Control-network push-wake transport.
3
- *
4
- * Lets a same-cadre peer — typically an always-on server that participates in a
5
- * strand and sees new activity — signal a hibernating cadre peer to bring that
6
- * strand online, pull pending activity, and re-hibernate. Push-wake travels the
7
- * **control network** (the per-party network connecting this party's own cadre
8
- * nodes), which is the only network a hibernating peer keeps connected.
9
- *
10
- * Modeled directly on `seed-bootstrap.ts`: a dedicated libp2p protocol id,
11
- * 4-byte big-endian length-prefixed JSON frames, `node.handle` for the receiver,
12
- * `node.dialProtocol` for the sender, and the shared `ControlStream` primitives
13
- * from `control-stream.ts`. The exchange is a single request → single ack on one
14
- * stream (like seed delivery), so each side reads to EOF (under a read timeout)
15
- * and decodes one frame via the shared {@link decodeLengthPrefixedFrame} guard.
16
- *
17
- * **Authorization (v1):** a wake is low-risk — it only causes the receiver to
18
- * spend resources coming online for a strand it already participates in — so the
19
- * receiver carries no per-request signature and instead defers entirely to the
20
- * injected `isMember` predicate. `CadreNode` injects its AUTHORIZED-membership
21
- * predicate there (`isAuthorizedMember`: the sender's `CadrePeer` row must carry
22
- * a voucher that verifies against an owner key in the receiver's node-local
23
- * trusted-owner anchor), so a peer that merely published rows into the replicated
24
- * control DB is refused. This module stays agnostic: it enforces whatever
25
- * predicate it is given.
26
- */
27
-
28
- import debug from 'debug';
29
- import type { Libp2p, Connection } from '@libp2p/interface';
30
- import type { Multiaddr } from '@multiformats/multiaddr';
31
- import type { StrandInstance, WakeRequest, WakeAck } from './types.js';
32
- import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
33
- import { type ControlStream, writeFrame, exchangeFrame, readStreamToEnd } from './control-stream.js';
34
- import { tryAddrsInTurn } from './peer-dial.js';
35
-
36
- const log = debug('sereus:cadre:strand-wake');
37
-
38
- /** Protocol id for control-network push-wake (parallel to `/sereus/seed/1.0.0`). */
39
- export const WAKE_PROTOCOL = '/sereus/strand-wake/1.0.0';
40
-
41
- /**
42
- * Maximum wake frame size. Wake messages are tiny (a strand id + short reason),
43
- * so this is a defensive cap — far below the 1MB seed ceiling — that bounds the
44
- * bytes a peer can make the receiver buffer per stream.
45
- */
46
- const MAX_WAKE_SIZE = 64 * 1024;
47
-
48
- /** Default time to wait for the ack before abandoning ONE wake dial attempt (ms). */
49
- const DEFAULT_WAKE_TIMEOUT_MS = 10_000;
50
-
51
- /**
52
- * Default budget for a WHOLE {@link dialWake} call, in ms — every candidate
53
- * address together, not each one.
54
- *
55
- * Without it the cost of a wake is (candidate count × {@link
56
- * DEFAULT_WAKE_TIMEOUT_MS}), a number nothing chooses or bounds. That is not
57
- * hypothetical: an address behind a dropped NAT mapping, or any host that
58
- * blackholes rather than sending a RST, burns its full attempt timeout instead
59
- * of failing in milliseconds, so a two-address peer costs 20 s and a
60
- * five-address one costs 50 s.
61
- *
62
- * This makes the TARGET PEER the unit rather than the address — the same
63
- * decision, for the same reason, as
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
68
- * reachable peer whose signaling address is stale still gets a genuine try at
69
- * its direct one; a peer needing longer than that is not "asleep and reachable",
70
- * which is the only case a wake is for. The last attempt inside the budget gets
71
- * whatever remains of it, so the call returns at the budget, not past it.
72
- *
73
- * Override per call with {@link DialWakeOptions.budgetMs} — tests that drive
74
- * dead addresses on purpose set it low so a dial's duration is a chosen number
75
- * rather than a transitive libp2p default stretched by machine load.
76
- */
77
- export const DEFAULT_WAKE_DIAL_BUDGET_MS = 20_000;
78
-
79
- /** Default time the receiver waits for an inbound wake frame before aborting (ms). */
80
- const DEFAULT_WAKE_READ_TIMEOUT_MS = 10_000;
81
-
82
- /** Default cap on concurrent inbound wake streams a single peer can pin open. */
83
- const DEFAULT_MAX_CONCURRENT_WAKES = 100;
84
-
85
- /**
86
- * Read a libp2p stream to EOF and decode the single length-prefixed JSON frame
87
- * it carries. Bounded by `timeoutMs` (a never-half-closing peer is aborted, not
88
- * awaited forever) and capped at {@link MAX_WAKE_SIZE}, reusing the shared
89
- * {@link decodeLengthPrefixedFrame} guard for the prefix/length checks.
90
- */
91
- async function readFrame<T>(stream: ControlStream, timeoutMs: number): Promise<T> {
92
- const data = await readStreamToEnd(stream, { maxBytes: MAX_WAKE_SIZE, timeoutMs, label: 'Wake' });
93
- const body = decodeLengthPrefixedFrame(data, MAX_WAKE_SIZE);
94
- return JSON.parse(new TextDecoder().decode(body)) as T;
95
- }
96
-
97
- /**
98
- * Dependencies the {@link StrandWakeService} receiver needs from its host
99
- * (`CadreNode`), injected so the service is testable without a full node.
100
- */
101
- export interface StrandWakeServiceOptions {
102
- /** Membership gate: is the remote peer a `CadrePeer` member of this cadre? */
103
- isMember(remotePeerId: string): Promise<boolean>;
104
- /** Look up a local strand instance by id (undefined if not participated in). */
105
- getStrand(strandId: string): StrandInstance | undefined;
106
- /**
107
- * Trigger the local wake path for a hibernating/idle strand. Wired to
108
- * `CadreNode.wakeStrand` (→ `HibernationManager` → `resumeStrand`), whose
109
- * resume coalescing prevents a push-wake racing a concurrent check-in.
110
- */
111
- wake(strandId: string): Promise<void>;
112
- /**
113
- * Time to wait for the inbound wake frame before aborting the read (ms).
114
- * Defaults to {@link DEFAULT_WAKE_READ_TIMEOUT_MS}. Bounds a buggy/compromised
115
- * own-cadre node that opens a stream and never half-closes its write end.
116
- */
117
- readTimeoutMs?: number;
118
- /**
119
- * Cap on concurrent inbound wake streams (defaults to
120
- * {@link DEFAULT_MAX_CONCURRENT_WAKES}). Over the cap, a non-accepting ack is
121
- * returned without invoking the wake path.
122
- */
123
- maxConcurrent?: number;
124
- }
125
-
126
- /**
127
- * Receiver side of the push-wake protocol. Registers a `WAKE_PROTOCOL` handler
128
- * on the control node and, for each inbound {@link WakeRequest}, gates on cadre
129
- * membership, then resumes the named strand if it is hibernating/idle and we
130
- * participate in it — replying with a {@link WakeAck}.
131
- */
132
- export class StrandWakeService {
133
- private readonly options: StrandWakeServiceOptions;
134
- private readonly readTimeoutMs: number;
135
- private readonly maxConcurrent: number;
136
- private node: Libp2p | null = null;
137
- /** In-flight inbound wake streams, used to enforce {@link maxConcurrent}. */
138
- private activeStreams = 0;
139
-
140
- constructor(options: StrandWakeServiceOptions) {
141
- this.options = options;
142
- this.readTimeoutMs = options.readTimeoutMs ?? DEFAULT_WAKE_READ_TIMEOUT_MS;
143
- this.maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_WAKES;
144
- }
145
-
146
- /** Number of in-flight inbound wake streams. */
147
- get activeCount(): number {
148
- return this.activeStreams;
149
- }
150
-
151
- /** Register the wake protocol handler on the control node. */
152
- initialize(node: Libp2p): void {
153
- this.node = node;
154
- // `runOnLimitedConnection: true` is REQUIRED for the relay path: a NAT'd
155
- // receiver is reached over a circuit-relay connection, which libp2p marks
156
- // "limited" (the relay caps its data/duration). Without this the receiver
157
- // would refuse the inbound wake stream on exactly the connection the
158
- // protocol is designed to use (see the relay note on `dialWake`).
159
- void node.handle(WAKE_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
160
- const remotePeerId = (rawConnection as Connection).remotePeer.toString();
161
- await this.handleStream(rawStream as ControlStream, remotePeerId);
162
- }, { runOnLimitedConnection: true });
163
- log('StrandWakeService registered handler: %s', WAKE_PROTOCOL);
164
- }
165
-
166
- /** Unregister the handler and release the node reference. */
167
- async shutdown(): Promise<void> {
168
- if (this.node) {
169
- await this.node.unhandle(WAKE_PROTOCOL);
170
- this.node = null;
171
- log('StrandWakeService shutdown');
172
- }
173
- }
174
-
175
- /**
176
- * Read the inbound request, decide + execute the wake, and write the ack.
177
- *
178
- * Three hardening layers, all reported as a non-accepting ack rather than a
179
- * dropped/hung stream: a concurrency cap (over {@link maxConcurrent}, reply
180
- * without touching the wake path), a read timeout (a peer that never
181
- * half-closes is aborted inside {@link readFrame}/`readStreamToEnd`), and the
182
- * existing malformed/oversized-frame guard.
183
- */
184
- private async handleStream(stream: ControlStream, remotePeerId: string): Promise<void> {
185
- log('Incoming wake request from: %s', remotePeerId);
186
-
187
- if (this.activeStreams >= this.maxConcurrent) {
188
- log('Rejecting wake from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrent);
189
- const ack: WakeAck = { accepted: false, reason: 'Too many concurrent wake requests' };
190
- try {
191
- writeFrame(stream, ack);
192
- } catch {
193
- // Ignore send errors on the reject path.
194
- }
195
- try {
196
- await stream.close();
197
- } catch {
198
- // Ignore close errors.
199
- }
200
- return;
201
- }
202
-
203
- this.activeStreams++;
204
- try {
205
- const request = await readFrame<WakeRequest>(stream, this.readTimeoutMs);
206
- const ack = await this.processWakeRequest(request, remotePeerId);
207
- writeFrame(stream, ack);
208
- } catch (err) {
209
- log('Error handling wake request from %s: %o', remotePeerId, err);
210
- const ack: WakeAck = {
211
- accepted: false,
212
- reason: err instanceof Error ? err.message : 'Unknown error',
213
- };
214
- try {
215
- writeFrame(stream, ack);
216
- } catch {
217
- // Ignore send errors on the error path.
218
- }
219
- } finally {
220
- this.activeStreams--;
221
- try {
222
- await stream.close();
223
- } catch {
224
- // Ignore close errors.
225
- }
226
- }
227
- }
228
-
229
- /**
230
- * Decide and execute the wake for a decoded request. Exposed (not private) so
231
- * the decision matrix can be unit-tested directly.
232
- *
233
- * - Non-member sender → rejected (`accepted: false`).
234
- * - Unknown / not-participated strand → rejected.
235
- * - Hibernating or idle strand → resumed via the wake path, then `accepted`.
236
- * - Already-live strand → no-op, `accepted` with current status.
237
- */
238
- async processWakeRequest(request: WakeRequest, remotePeerId: string): Promise<WakeAck> {
239
- // The injected membership predicate is the whole v1 authorization (CadreNode
240
- // supplies the voucher-anchored one); only a peer it admits may ask us to wake.
241
- if (!(await this.options.isMember(remotePeerId))) {
242
- log('Rejecting wake from non-member %s', remotePeerId);
243
- return { accepted: false, reason: 'Sender is not a cadre member' };
244
- }
245
-
246
- const instance = this.options.getStrand(request.strandId);
247
- if (!instance) {
248
- log('Rejecting wake for unknown/unparticipated strand %s', request.strandId);
249
- return { accepted: false, reason: 'Strand not found or not participated in' };
250
- }
251
-
252
- if (instance.status === 'hibernating' || instance.status === 'idle') {
253
- log('Waking strand %s on push from %s (reason=%s)', request.strandId, remotePeerId, request.reason ?? 'unspecified');
254
- await this.options.wake(request.strandId);
255
- } else {
256
- log('Strand %s already %s; push-wake is a no-op', request.strandId, instance.status);
257
- }
258
-
259
- // The wake path mutates the shared instance, so re-read its current status.
260
- return { accepted: true, status: instance.status };
261
- }
262
- }
263
-
264
- /** Options for {@link dialWake}. */
265
- export interface DialWakeOptions {
266
- /** Per-ATTEMPT timeout in ms (default {@link DEFAULT_WAKE_TIMEOUT_MS}). */
267
- timeoutMs?: number;
268
- /**
269
- * Budget for the whole call — every candidate together (default
270
- * {@link DEFAULT_WAKE_DIAL_BUDGET_MS}).
271
- */
272
- budgetMs?: number;
273
- /** Override the protocol id (defaults to {@link WAKE_PROTOCOL}). */
274
- protocolId?: string;
275
- }
276
-
277
- /**
278
- * Sender side: dial a target's control-network address(es), send a
279
- * {@link WakeRequest} over `WAKE_PROTOCOL`, and return the peer's {@link WakeAck}.
280
- *
281
- * Tries each candidate address in order (signaling/relay first, as produced by
282
- * `CadreNode.resolvePeerAddrs`) until one dials, so a NAT'd peer is reachable via
283
- * its circuit-relay address. Each attempt is bounded by `timeoutMs` and the call
284
- * as a whole by `budgetMs` (see {@link DEFAULT_WAKE_DIAL_BUDGET_MS}); a candidate
285
- * the budget leaves no room for is reported as untried rather than silently
286
- * dropped.
287
- *
288
- * Throws if no address is dialable — with an error naming EVERY candidate and
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.
294
- *
295
- * The candidate loop is deliberately explicit rather than one
296
- * `dialProtocol(addrs)` call. libp2p sorts any multi-address dial with
297
- * `defaultAddressSorter`, whose `circuitRelayAddressesLast` pass would demote
298
- * exactly the signaling address this ordering puts first — silently inverting
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.
301
- */
302
- export async function dialWake(
303
- node: Libp2p,
304
- addrs: Multiaddr[],
305
- request: WakeRequest,
306
- options: DialWakeOptions = {}
307
- ): Promise<WakeAck> {
308
- if (addrs.length === 0) {
309
- throw new Error('No dialable address for wake target');
310
- }
311
- const protocolId = options.protocolId ?? WAKE_PROTOCOL;
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));
320
- }
321
-
322
- /**
323
- * Open one stream, send the request, half-close, and read the ack.
324
- *
325
- * `signal` is the per-attempt deadline from {@link dialWake}: it goes to
326
- * `dialProtocol` so a timeout during connect aborts the dial, and into
327
- * {@link exchangeFrame} so a timeout after the stream is open resets it —
328
- * releasing the otherwise unbounded ack-read.
329
- */
330
- async function sendWake(
331
- node: Libp2p,
332
- addr: Multiaddr,
333
- protocolId: string,
334
- request: WakeRequest,
335
- timeoutMs: number,
336
- signal: AbortSignal,
337
- ): Promise<WakeAck> {
338
- // `runOnLimitedConnection: true`: the target may be reachable only over a
339
- // circuit-relay connection (the signaling-first addr), which libp2p marks
340
- // "limited". The wake exchange is a single tiny request→ack well within the
341
- // relay's data/duration cap, so opening it on the limited connection is safe
342
- // and is the whole point of dialing the relay address.
343
- const rawStream = await node.dialProtocol(addr, protocolId, { runOnLimitedConnection: true, signal });
344
-
345
- const ack = await exchangeFrame(
346
- rawStream as unknown as ControlStream,
347
- signal,
348
- request,
349
- (stream) => readFrame<WakeAck>(stream, timeoutMs),
350
- 'Wake dial aborted by timeout',
351
- );
352
-
353
- log('Wake ack from %s: accepted=%s status=%s', addr.toString(), ack.accepted, ack.status ?? '-');
354
- return ack;
355
- }
1
+ /**
2
+ * Control-network push-wake transport.
3
+ *
4
+ * Lets a same-cadre peer — typically an always-on server that participates in a
5
+ * strand and sees new activity — signal a hibernating cadre peer to bring that
6
+ * strand online, pull pending activity, and re-hibernate. Push-wake travels the
7
+ * **control network** (the per-party network connecting this party's own cadre
8
+ * nodes), which is the only network a hibernating peer keeps connected.
9
+ *
10
+ * Modeled directly on `seed-bootstrap.ts`: a dedicated libp2p protocol id,
11
+ * 4-byte big-endian length-prefixed JSON frames, `node.handle` for the receiver,
12
+ * `node.dialProtocol` for the sender, and the shared `ControlStream` primitives
13
+ * from `control-stream.ts`. The exchange is a single request → single ack on one
14
+ * stream (like seed delivery), so each side reads to EOF (under a read timeout)
15
+ * and decodes one frame via the shared {@link decodeLengthPrefixedFrame} guard.
16
+ *
17
+ * **Authorization (v1):** a wake is low-risk — it only causes the receiver to
18
+ * spend resources coming online for a strand it already participates in — so the
19
+ * receiver carries no per-request signature and instead defers entirely to the
20
+ * injected `isMember` predicate. `CadreNode` injects its AUTHORIZED-membership
21
+ * predicate there (`isAuthorizedMember`: the sender's `CadrePeer` row must carry
22
+ * a voucher that verifies against an owner key in the receiver's node-local
23
+ * trusted-owner anchor), so a peer that merely published rows into the replicated
24
+ * control DB is refused. This module stays agnostic: it enforces whatever
25
+ * predicate it is given.
26
+ */
27
+
28
+ import debug from 'debug';
29
+ import type { Libp2p, Connection } from '@libp2p/interface';
30
+ import type { Multiaddr } from '@multiformats/multiaddr';
31
+ import type { StrandInstance, WakeRequest, WakeAck } from './types.js';
32
+ import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
33
+ import { type ControlStream, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
34
+ import { tryAddrsInTurn } from './peer-dial.js';
35
+ import { relayedRequestBudgetMs } from './link-budget.js';
36
+
37
+ const log = debug('sereus:cadre:strand-wake');
38
+
39
+ /** Protocol id for control-network push-wake (parallel to `/sereus/seed/1.0.0`). */
40
+ export const WAKE_PROTOCOL = '/sereus/strand-wake/1.0.0';
41
+
42
+ /**
43
+ * Maximum wake frame size. Wake messages are tiny (a strand id + short reason),
44
+ * so this is a defensive cap — far below the 1MB seed ceiling — that bounds the
45
+ * bytes a peer can make the receiver buffer per stream.
46
+ */
47
+ const MAX_WAKE_SIZE = 64 * 1024;
48
+
49
+ /**
50
+ * Default deadline for ONE wake attempt — dial, request, ack — at the default declared link
51
+ * (ms): `relayedRequestBudgetMs` in `link-budget.ts`, because the target may be reachable
52
+ * only through a relay and the attempt has to open that connection before the exchange.
53
+ * {@link dialWake} derives it from {@link DialWakeOptions.linkRoundTripMs} instead when given.
54
+ *
55
+ * It holds only link work because the receiver acks as soon as it has DECIDED, before the
56
+ * wake itself runs ({@link StrandWakeService.processWakeRequest}).
57
+ *
58
+ * NOTE: the receiver's membership check (two live control reads) runs inside this deadline and
59
+ * is not counted. In steady state those reads touch only held blocks and do not consult the
60
+ * cohort. If a wake or address request is seen timing out while the receiver's membership read
61
+ * is consulting, count one membership decision in this deadline or answer the check from the
62
+ * materialized authorized-peer snapshot. See docs/cadre-consistency.md → "Deadlines Over
63
+ * Optimystic's Reads and Commits".
64
+ */
65
+ export const DEFAULT_WAKE_TIMEOUT_MS = relayedRequestBudgetMs();
66
+
67
+ /**
68
+ * Whole attempts one {@link dialWake} call budgets for: a stale signaling address, then the
69
+ * direct address that works.
70
+ */
71
+ export const WAKE_DIAL_ATTEMPTS = 2;
72
+
73
+ /**
74
+ * Default budget for a WHOLE {@link dialWake} call at the default declared link, in ms —
75
+ * every candidate address together, not each one: {@link WAKE_DIAL_ATTEMPTS} attempt
76
+ * deadlines.
77
+ *
78
+ * Without it the cost of a wake is (candidate count × {@link
79
+ * DEFAULT_WAKE_TIMEOUT_MS}), a number nothing chooses or bounds. That is not
80
+ * hypothetical: an address behind a dropped NAT mapping, or any host that
81
+ * blackholes rather than sending a RST, burns its full attempt timeout instead
82
+ * of failing in milliseconds, so a five-address peer would cost five attempts.
83
+ *
84
+ * This makes the TARGET PEER the unit rather than the address — the same
85
+ * decision, for the same reason, as
86
+ * `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` in `peer-dial.ts`, though not the same
87
+ * count: that one is sized to fit several dead addresses ahead of a working one,
88
+ * while a wake target is expected to be awake and reachable on its first or second
89
+ * address. Two whole attempts so a reachable peer whose signaling address is stale
90
+ * still gets a genuine try at its direct one; a peer needing longer than that is
91
+ * not "asleep and reachable", which is the only case a wake is for. The last
92
+ * attempt inside the budget gets whatever remains of it, so the call returns at
93
+ * the budget, not past it.
94
+ *
95
+ * Override per call with {@link DialWakeOptions.budgetMs} — tests that drive
96
+ * dead addresses on purpose set it low so a dial's duration is a chosen number
97
+ * rather than a transitive libp2p default stretched by machine load.
98
+ */
99
+ export const DEFAULT_WAKE_DIAL_BUDGET_MS = WAKE_DIAL_ATTEMPTS * DEFAULT_WAKE_TIMEOUT_MS;
100
+
101
+ /** Default time the receiver waits for an inbound wake frame before aborting (ms). */
102
+ // eslint-disable-next-line no-restricted-syntax -- link-independent: a receiver cap on one small request frame on a stream the peer already opened; it bounds a peer that opens a stream and never sends, not the dial
103
+ const DEFAULT_WAKE_READ_TIMEOUT_MS = 10_000;
104
+
105
+ /** Default cap on concurrent inbound wake streams a single peer can pin open. */
106
+ const DEFAULT_MAX_CONCURRENT_WAKES = 100;
107
+
108
+ /**
109
+ * Read a libp2p stream to EOF and decode the single length-prefixed JSON frame
110
+ * it carries. Bounded by `timeoutMs` (a never-half-closing peer is aborted, not
111
+ * awaited forever) and capped at {@link MAX_WAKE_SIZE}, reusing the shared
112
+ * {@link decodeLengthPrefixedFrame} guard for the prefix/length checks.
113
+ */
114
+ async function readFrame<T>(stream: ControlStream, timeoutMs: number): Promise<T> {
115
+ const data = await readStreamToEnd(stream, { maxBytes: MAX_WAKE_SIZE, timeoutMs, label: 'Wake' });
116
+ const body = decodeLengthPrefixedFrame(data, MAX_WAKE_SIZE);
117
+ return JSON.parse(new TextDecoder().decode(body)) as T;
118
+ }
119
+
120
+ /**
121
+ * Dependencies the {@link StrandWakeService} receiver needs from its host
122
+ * (`CadreNode`), injected so the service is testable without a full node.
123
+ */
124
+ export interface StrandWakeServiceOptions {
125
+ /** Membership gate: is the remote peer a `CadrePeer` member of this cadre? */
126
+ isMember(remotePeerId: string): Promise<boolean>;
127
+ /** Look up a local strand instance by id (undefined if not participated in). */
128
+ getStrand(strandId: string): StrandInstance | undefined;
129
+ /**
130
+ * Trigger the local wake path for a hibernating/idle strand. Wired to
131
+ * `CadreNode.wakeStrand` (→ `HibernationManager` → `resumeStrand`); `resumeStrand`
132
+ * joins any resume already in flight, so a push-wake racing a check-in shares its
133
+ * runtime build.
134
+ *
135
+ * Started, not awaited: the ack goes back before the strand is up, and a wake
136
+ * that fails afterwards is logged here rather than reported to the sender.
137
+ */
138
+ wake(strandId: string): Promise<void>;
139
+ /**
140
+ * Time to wait for the inbound wake frame before aborting the read (ms).
141
+ * Defaults to {@link DEFAULT_WAKE_READ_TIMEOUT_MS}. Bounds a buggy/compromised
142
+ * own-cadre node that opens a stream and never half-closes its write end.
143
+ */
144
+ readTimeoutMs?: number;
145
+ /**
146
+ * Cap on concurrent inbound wake streams (defaults to
147
+ * {@link DEFAULT_MAX_CONCURRENT_WAKES}). Over the cap, a non-accepting ack is
148
+ * returned without invoking the wake path.
149
+ *
150
+ * It counts streams, not wakes: a wake started after its ack is no longer
151
+ * counted. Those are bounded by the strands this node participates in instead,
152
+ * since an unknown strand is refused before any wake starts and the wake path
153
+ * coalesces per strand.
154
+ */
155
+ maxConcurrent?: number;
156
+ }
157
+
158
+ /**
159
+ * Receiver side of the push-wake protocol. Registers a `WAKE_PROTOCOL` handler
160
+ * on the control node and, for each inbound {@link WakeRequest}, gates on cadre
161
+ * membership, replies with a {@link WakeAck}, and starts resuming the named
162
+ * strand if it is hibernating/idle and we participate in it.
163
+ */
164
+ export class StrandWakeService {
165
+ private readonly options: StrandWakeServiceOptions;
166
+ private readonly readTimeoutMs: number;
167
+ private readonly maxConcurrent: number;
168
+ private node: Libp2p | null = null;
169
+ /** In-flight inbound wake streams, used to enforce {@link maxConcurrent}. */
170
+ private activeStreams = 0;
171
+
172
+ constructor(options: StrandWakeServiceOptions) {
173
+ this.options = options;
174
+ this.readTimeoutMs = options.readTimeoutMs ?? DEFAULT_WAKE_READ_TIMEOUT_MS;
175
+ this.maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_WAKES;
176
+ }
177
+
178
+ /** Number of in-flight inbound wake streams. */
179
+ get activeCount(): number {
180
+ return this.activeStreams;
181
+ }
182
+
183
+ /**
184
+ * Register the wake protocol handler on the control node. Rejects when libp2p
185
+ * refuses the registration; the node reference is kept only once the handler
186
+ * is in place.
187
+ */
188
+ async initialize(node: Libp2p): Promise<void> {
189
+ // `runOnLimitedConnection: true` is REQUIRED for the relay path: a NAT'd
190
+ // receiver is reached over a circuit-relay connection, which libp2p marks
191
+ // "limited" (the relay caps its data/duration). Without this the receiver
192
+ // would refuse the inbound wake stream on exactly the connection the
193
+ // protocol is designed to use (see the relay note on `dialWake`).
194
+ await node.handle(WAKE_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
195
+ const remotePeerId = (rawConnection as Connection).remotePeer.toString();
196
+ await this.handleStream(rawStream as ControlStream, remotePeerId);
197
+ }, { runOnLimitedConnection: true });
198
+ this.node = node;
199
+ log('StrandWakeService registered handler: %s', WAKE_PROTOCOL);
200
+ }
201
+
202
+ /** Unregister the handler and release the node reference. */
203
+ async shutdown(): Promise<void> {
204
+ if (this.node) {
205
+ await this.node.unhandle(WAKE_PROTOCOL);
206
+ this.node = null;
207
+ log('StrandWakeService shutdown');
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Read the inbound request, decide the wake, and write the ack.
213
+ *
214
+ * Three hardening layers, all reported as a non-accepting ack rather than a
215
+ * dropped/hung stream: a concurrency cap (over {@link maxConcurrent}, reply
216
+ * without touching the wake path), a read timeout (a peer that never
217
+ * half-closes is aborted inside {@link readFrame}/`readStreamToEnd`), and the
218
+ * existing malformed/oversized-frame guard.
219
+ */
220
+ private async handleStream(stream: ControlStream, remotePeerId: string): Promise<void> {
221
+ log('Incoming wake request from: %s', remotePeerId);
222
+
223
+ if (this.activeStreams >= this.maxConcurrent) {
224
+ log('Rejecting wake from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrent);
225
+ await replyAndClose(stream, { accepted: false, reason: 'Too many concurrent wake requests' } satisfies WakeAck, 'Wake');
226
+ return;
227
+ }
228
+
229
+ this.activeStreams++;
230
+ try {
231
+ await replyAndClose(stream, await this.answerStream(stream, remotePeerId), 'Wake');
232
+ } finally {
233
+ this.activeStreams--;
234
+ }
235
+ }
236
+
237
+ /** Read and decide one inbound request; any failure becomes a non-accepting ack. */
238
+ private async answerStream(stream: ControlStream, remotePeerId: string): Promise<WakeAck> {
239
+ try {
240
+ const request = await readFrame<WakeRequest>(stream, this.readTimeoutMs);
241
+ return await this.processWakeRequest(request, remotePeerId);
242
+ } catch (err) {
243
+ log('Error handling wake request from %s: %o', remotePeerId, err);
244
+ return { accepted: false, reason: err instanceof Error ? err.message : 'Unknown error' };
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Decide the wake for a decoded request and start it. Exposed (not private) so
250
+ * the decision matrix can be unit-tested directly.
251
+ *
252
+ * - Non-member sender → rejected (`accepted: false`).
253
+ * - Unknown / not-participated strand → rejected.
254
+ * - Hibernating or idle strand → `accepted` with that status, and a wake started.
255
+ * - Already-live strand → no-op, `accepted` with current status.
256
+ *
257
+ * The wake is not awaited, so the ack means "a wake was started", not "the
258
+ * strand is up". Awaiting it would put the whole resume — a sibling address
259
+ * collection, a strand node build, a relay reservation drive — inside the
260
+ * sender's attempt deadline, coupling that deadline to this node's own budgets.
261
+ */
262
+ async processWakeRequest(request: WakeRequest, remotePeerId: string): Promise<WakeAck> {
263
+ // The injected membership predicate is the whole v1 authorization (CadreNode
264
+ // supplies the voucher-anchored one); only a peer it admits may ask us to wake.
265
+ if (!(await this.options.isMember(remotePeerId))) {
266
+ log('Rejecting wake from non-member %s', remotePeerId);
267
+ return { accepted: false, reason: 'Sender is not a cadre member' };
268
+ }
269
+
270
+ const instance = this.options.getStrand(request.strandId);
271
+ if (!instance) {
272
+ log('Rejecting wake for unknown/unparticipated strand %s', request.strandId);
273
+ return { accepted: false, reason: 'Strand not found or not participated in' };
274
+ }
275
+
276
+ // Read before the wake starts: the wake path mutates the shared instance.
277
+ const status = instance.status;
278
+ if (status === 'hibernating' || status === 'idle') {
279
+ log('Waking strand %s on push from %s (reason=%s)', request.strandId, remotePeerId, request.reason ?? 'unspecified');
280
+ this.startWake(request.strandId);
281
+ } else {
282
+ log('Strand %s already %s; push-wake is a no-op', request.strandId, status);
283
+ }
284
+
285
+ return { accepted: true, status };
286
+ }
287
+
288
+ /**
289
+ * Start a wake without awaiting it — the same fire-and-forget an activity-driven
290
+ * local wake uses (`HibernationManager`), which also coalesces the two.
291
+ */
292
+ private startWake(strandId: string): void {
293
+ void this.options.wake(strandId).catch((err: unknown) => {
294
+ log('Push-wake of strand %s failed after it was accepted: %o', strandId, err);
295
+ });
296
+ }
297
+ }
298
+
299
+ /** Options for {@link dialWake}. */
300
+ export interface DialWakeOptions {
301
+ /**
302
+ * The sender's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
303
+ * both deadlines below are derived when not given. Unset means the declared default.
304
+ */
305
+ linkRoundTripMs?: number;
306
+ /** Per-ATTEMPT timeout in ms (default: derived as {@link DEFAULT_WAKE_TIMEOUT_MS} is). */
307
+ timeoutMs?: number;
308
+ /**
309
+ * Budget for the whole call — every candidate together (default:
310
+ * {@link WAKE_DIAL_ATTEMPTS} × the per-attempt timeout, as {@link DEFAULT_WAKE_DIAL_BUDGET_MS}).
311
+ */
312
+ budgetMs?: number;
313
+ /** Override the protocol id (defaults to {@link WAKE_PROTOCOL}). */
314
+ protocolId?: string;
315
+ }
316
+
317
+ /**
318
+ * Sender side: dial a target's control-network address(es), send a
319
+ * {@link WakeRequest} over `WAKE_PROTOCOL`, and return the peer's {@link WakeAck}.
320
+ *
321
+ * Tries each candidate address in order (signaling/relay first, as produced by
322
+ * `CadreNode.resolvePeerAddrs`) until one dials, so a NAT'd peer is reachable via
323
+ * its circuit-relay address. Each attempt is bounded by `timeoutMs` and the call
324
+ * as a whole by `budgetMs` (see {@link DEFAULT_WAKE_DIAL_BUDGET_MS}); a candidate
325
+ * the budget leaves no room for is reported as untried rather than silently
326
+ * dropped.
327
+ *
328
+ * Throws if no address is dialable — with an error naming EVERY candidate and
329
+ * why it failed, not merely the last one ({@link tryAddrsInTurn}). That
330
+ * distinction is not cosmetic: with only the last candidate's message the
331
+ * failure that mattered (usually the signaling address, tried first) is
332
+ * invisible outside a debug log, and the surfaced message points at whichever
333
+ * address happened to be tried last.
334
+ *
335
+ * The candidate loop is deliberately explicit rather than one
336
+ * `dialProtocol(addrs)` call. libp2p sorts any multi-address dial with
337
+ * `defaultAddressSorter`, whose `circuitRelayAddressesLast` pass would demote
338
+ * exactly the signaling address this ordering puts first — silently inverting
339
+ * it, with no per-dial sorter override to opt out of. For the same reason this
340
+ * does not use `dialPeerAddrs`, which puts relayed addresses last.
341
+ *
342
+ * NOTE: unlike `dialPeerAddrs`, this does not drop addresses that relay through this node, so a
343
+ * relay waking a peer that holds a reservation on it tries that address and gets `Can not dial
344
+ * self` (fast, then the next candidate). If wakes are ever sent from a relay to its own
345
+ * reservation holders routinely, share `dialPeerAddrs`'s filter here.
346
+ */
347
+ export async function dialWake(
348
+ node: Libp2p,
349
+ addrs: Multiaddr[],
350
+ request: WakeRequest,
351
+ options: DialWakeOptions = {}
352
+ ): Promise<WakeAck> {
353
+ if (addrs.length === 0) {
354
+ throw new Error('No dialable address for wake target');
355
+ }
356
+ const protocolId = options.protocolId ?? WAKE_PROTOCOL;
357
+ const perAddressMs = options.timeoutMs ?? relayedRequestBudgetMs(options.linkRoundTripMs);
358
+ const budget = {
359
+ perAddressMs,
360
+ totalMs: options.budgetMs ?? WAKE_DIAL_ATTEMPTS * perAddressMs,
361
+ };
362
+ // The attempt's signal aborts the in-flight dialProtocol and resets the live
363
+ // stream, so neither the connect nor the ack-read leaks.
364
+ return await tryAddrsInTurn(addrs, budget, 'Wake dial', (addr, signal, attemptMs) =>
365
+ sendWake(node, addr, protocolId, request, attemptMs, signal));
366
+ }
367
+
368
+ /**
369
+ * Open one stream, send the request, half-close, and read the ack.
370
+ *
371
+ * `signal` is the per-attempt deadline from {@link dialWake}: it goes to
372
+ * `dialProtocol` so a timeout during connect aborts the dial, and into
373
+ * {@link exchangeFrame} so a timeout after the stream is open resets it —
374
+ * releasing the otherwise unbounded ack-read.
375
+ */
376
+ async function sendWake(
377
+ node: Libp2p,
378
+ addr: Multiaddr,
379
+ protocolId: string,
380
+ request: WakeRequest,
381
+ timeoutMs: number,
382
+ signal: AbortSignal,
383
+ ): Promise<WakeAck> {
384
+ // `runOnLimitedConnection: true`: the target may be reachable only over a
385
+ // circuit-relay connection (the signaling-first addr), which libp2p marks
386
+ // "limited". The wake exchange is a single tiny request→ack well within the
387
+ // relay's data/duration cap, so opening it on the limited connection is safe
388
+ // and is the whole point of dialing the relay address.
389
+ const rawStream = await node.dialProtocol(addr, protocolId, { runOnLimitedConnection: true, signal });
390
+
391
+ const ack = await exchangeFrame(
392
+ rawStream as unknown as ControlStream,
393
+ signal,
394
+ request,
395
+ (stream) => readFrame<WakeAck>(stream, timeoutMs),
396
+ 'Wake dial aborted by timeout',
397
+ );
398
+
399
+ log('Wake ack from %s: accepted=%s status=%s', addr.toString(), ack.accepted, ack.status ?? '-');
400
+ return ack;
401
+ }