@serfab/cadre-core 1.9.0 → 1.11.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 (119) hide show
  1. package/dist/cadre-node.d.ts +148 -44
  2. package/dist/cadre-node.js +363 -139
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-authorization.d.ts +2 -2
  5. package/dist/control-authorization.js +1 -0
  6. package/dist/control-authorization.js.map +1 -1
  7. package/dist/control-cohort.d.ts +1 -1
  8. package/dist/control-cohort.js +1 -1
  9. package/dist/control-database.d.ts +105 -14
  10. package/dist/control-database.js +242 -73
  11. package/dist/control-database.js.map +1 -1
  12. package/dist/control-formation-recorder.d.ts +6 -6
  13. package/dist/control-formation-recorder.js +7 -8
  14. package/dist/control-formation-recorder.js.map +1 -1
  15. package/dist/control-schema.d.ts +1 -1
  16. package/dist/control-schema.js +1066 -979
  17. package/dist/control-schema.js.map +1 -1
  18. package/dist/control-write-retry.d.ts +21 -15
  19. package/dist/control-write-retry.js +21 -15
  20. package/dist/control-write-retry.js.map +1 -1
  21. package/dist/diagnostics/connection-path.d.ts +1 -18
  22. package/dist/diagnostics/connection-path.js +1 -20
  23. package/dist/diagnostics/connection-path.js.map +1 -1
  24. package/dist/identity-key.d.ts +0 -30
  25. package/dist/identity-key.js +7 -40
  26. package/dist/identity-key.js.map +1 -1
  27. package/dist/index.d.ts +8 -5
  28. package/dist/index.js +12 -6
  29. package/dist/index.js.map +1 -1
  30. package/dist/invitation-bootstrap.d.ts +32 -0
  31. package/dist/invitation-bootstrap.js +30 -0
  32. package/dist/invitation-bootstrap.js.map +1 -0
  33. package/dist/link-budget.d.ts +204 -119
  34. package/dist/link-budget.js +208 -120
  35. package/dist/link-budget.js.map +1 -1
  36. package/dist/membership-connection-gater.d.ts +3 -3
  37. package/dist/membership-connection-gater.js +3 -3
  38. package/dist/peer-dial.d.ts +18 -14
  39. package/dist/peer-dial.js +18 -14
  40. package/dist/peer-dial.js.map +1 -1
  41. package/dist/peer-join-backfill.d.ts +3 -3
  42. package/dist/pending-join-runner.d.ts +186 -0
  43. package/dist/pending-join-runner.js +649 -0
  44. package/dist/pending-join-runner.js.map +1 -0
  45. package/dist/push-fanout.d.ts +1 -1
  46. package/dist/push-fanout.js +1 -1
  47. package/dist/relay-stun.d.ts +24 -0
  48. package/dist/relay-stun.js +51 -0
  49. package/dist/relay-stun.js.map +1 -0
  50. package/dist/seed-bootstrap.d.ts +2 -2
  51. package/dist/seed-bootstrap.js +5 -5
  52. package/dist/strand-addr-protocol.js +2 -2
  53. package/dist/strand-formation-deadlines.d.ts +5 -5
  54. package/dist/strand-formation-deadlines.js +5 -5
  55. package/dist/strand-formation-manager.d.ts +18 -4
  56. package/dist/strand-formation-manager.js +48 -33
  57. package/dist/strand-formation-manager.js.map +1 -1
  58. package/dist/strand-formation-protocol.d.ts +71 -14
  59. package/dist/strand-formation-protocol.js +222 -47
  60. package/dist/strand-formation-protocol.js.map +1 -1
  61. package/dist/strand-formation-rejection.d.ts +94 -0
  62. package/dist/strand-formation-rejection.js +77 -0
  63. package/dist/strand-formation-rejection.js.map +1 -0
  64. package/dist/strand-instance-manager.js +4 -1
  65. package/dist/strand-instance-manager.js.map +1 -1
  66. package/dist/strand-membership-writer.js +32 -32
  67. package/dist/strand-solicitation.d.ts +11 -3
  68. package/dist/strand-solicitation.js +9 -0
  69. package/dist/strand-solicitation.js.map +1 -1
  70. package/dist/types.d.ts +103 -17
  71. package/dist/types.js +5 -9
  72. package/dist/types.js.map +1 -1
  73. package/package.json +22 -22
  74. package/src/cadre-node.ts +8654 -8400
  75. package/src/control-authorization.ts +2 -1
  76. package/src/control-cohort.ts +128 -128
  77. package/src/control-database.ts +303 -82
  78. package/src/control-formation-recorder.ts +353 -354
  79. package/src/control-read-retry.ts +216 -216
  80. package/src/control-schema.ts +1078 -991
  81. package/src/control-write-retry.ts +567 -561
  82. package/src/diagnostics/connection-path.ts +1 -29
  83. package/src/formation-approval.ts +610 -610
  84. package/src/hibernation-manager.ts +638 -638
  85. package/src/identity-key.ts +7 -62
  86. package/src/index.ts +644 -627
  87. package/src/invitation-bootstrap.ts +40 -0
  88. package/src/joined-strand-store.ts +498 -498
  89. package/src/link-budget.ts +684 -583
  90. package/src/membership-connection-gater.ts +570 -570
  91. package/src/node-local-snapshot.ts +298 -298
  92. package/src/peer-dial.ts +315 -311
  93. package/src/peer-join-backfill.ts +710 -710
  94. package/src/pending-join-runner.ts +735 -0
  95. package/src/push-fanout.ts +1 -1
  96. package/src/relay-reservation.ts +1121 -1121
  97. package/src/relay-server.ts +98 -98
  98. package/src/relay-stun.ts +58 -0
  99. package/src/seed-bootstrap.ts +1448 -1448
  100. package/src/strand-addr-protocol.ts +547 -547
  101. package/src/strand-database.ts +287 -287
  102. package/src/strand-first-sync-gate.ts +391 -391
  103. package/src/strand-formation-deadlines.ts +227 -227
  104. package/src/strand-formation-manager.ts +725 -700
  105. package/src/strand-formation-protocol.ts +1036 -802
  106. package/src/strand-formation-rejection.ts +129 -0
  107. package/src/strand-fret-addrs.ts +145 -145
  108. package/src/strand-instance-manager.ts +1789 -1786
  109. package/src/strand-membership-writer.ts +1974 -1974
  110. package/src/strand-network-state-file.ts +40 -40
  111. package/src/strand-network-state.ts +243 -243
  112. package/src/strand-revocation-enforcer.ts +654 -654
  113. package/src/strand-solicitation.ts +532 -519
  114. package/src/strand-wake-protocol.ts +401 -401
  115. package/src/types.ts +2222 -2140
  116. package/dist/diagnostics/webrtc-turn-tracker.d.ts +0 -50
  117. package/dist/diagnostics/webrtc-turn-tracker.js +0 -196
  118. package/dist/diagnostics/webrtc-turn-tracker.js.map +0 -1
  119. package/src/diagnostics/webrtc-turn-tracker.ts +0 -222
@@ -1,638 +1,638 @@
1
- import debug from 'debug';
2
- import type {
3
- StrandInstance,
4
- LatencyHint,
5
- HibernationTimeouts,
6
- HibernationConfig
7
- } from './types.js';
8
- import { HIBERNATION_TIMEOUTS } from './types.js';
9
-
10
- const log = debug('sereus:cadre:hibernation');
11
-
12
- /** An armed check-in: its timer, and the instance the chain reschedules. */
13
- interface PendingCheckIn {
14
- timer: ReturnType<typeof setTimeout>;
15
- instance: StrandInstance;
16
- }
17
-
18
- /**
19
- * Callbacks for hibernation state changes
20
- */
21
- export interface HibernationCallbacks {
22
- onIdle: (strandId: string) => Promise<void>;
23
- onHibernate: (strandId: string) => Promise<void>;
24
- onWake: (strandId: string) => Promise<void>;
25
- /**
26
- * Perform a real cohort check-in for a hibernating strand. The implementation
27
- * (`CadreNode.handleStrandCheckIn`) resumes the strand, gives it a bounded
28
- * window to connect to reachable cohort peers and surface pending activity,
29
- * then re-hibernates if still idle. The manager AWAITS this before scheduling
30
- * the next (longer-delayed) check-in, so a slow check-in never overlaps the
31
- * next tick. After it resolves the manager inspects `instance.status`: a
32
- * strand left non-`hibernating` is treated as woken (backoff resets on the
33
- * next hibernation); a strand left `hibernating` escalates the backoff. While
34
- * a wake or probe holds the strand, the chain passes to it instead.
35
- */
36
- onCheckIn: (strandId: string) => Promise<void>;
37
- /**
38
- * Whether the strand's runtime is being released right now
39
- * (`StrandInstanceManager.isQuiescing`): it may still read live, but it is on its way to
40
- * `hibernating`, so activity on it wakes it as activity on a hibernating strand does.
41
- */
42
- isQuiescing: (strandId: string) => boolean;
43
- }
44
-
45
- /**
46
- * Manages strand hibernation state transitions based on activity.
47
- *
48
- * State machine:
49
- * active → idle (after idleTimeout with no activity)
50
- * idle → hibernating (after hibernateTimeout with no activity)
51
- * idle → active (on activity)
52
- * hibernating → active (on wake signal or check-in with pending activity)
53
- */
54
- export class HibernationManager {
55
- private readonly config: HibernationConfig;
56
- private readonly callbacks: HibernationCallbacks;
57
- private readonly timers: Map<string, ReturnType<typeof setTimeout>> = new Map();
58
- /**
59
- * Pending check-in timers, one per hibernating strand. Unlike the old fixed
60
- * `setInterval`, these are single-shot `setTimeout`s rescheduled by
61
- * {@link runCheckIn} with an escalating delay — so a long-running `onCheckIn`
62
- * can never overlap the next tick, and the period adapts to the backoff.
63
- */
64
- private readonly checkInTimers: Map<string, PendingCheckIn> = new Map();
65
- /**
66
- * In-flight wake promises keyed by strandId. Coalesces overlapping wake
67
- * triggers (two near-simultaneous activities, or activity racing a force wake)
68
- * so `onWake` — and the libp2p-node rebuild it drives — runs at most once per
69
- * concurrent wake.
70
- */
71
- private readonly wakePromises: Map<string, Promise<void>> = new Map();
72
- /**
73
- * Hibernating strands whose armed check-in a wake cancelled, held until that wake
74
- * settles. A wake that leaves the strand hibernating again (a failed rebuild, or a probe's
75
- * window that re-quiesced it) re-arms the chain from here ({@link rearmAfterWake}); without
76
- * it the strand is left with no runtime and nothing scheduled to retry.
77
- */
78
- private readonly checkInsCancelledByWake: Set<string> = new Set();
79
- /**
80
- * Strands an on-demand probe (`CadreNode.serviceWake`) holds from its wake to the end of its
81
- * window, with whether a check-in chain was armed when it began. While held, the probe's end
82
- * ({@link endProbe}) — not the wake's settle — sets the timers, since the window may
83
- * re-hibernate the strand after the wake settled.
84
- */
85
- private readonly probes: Map<string, { hadCheckInChain: boolean }> = new Map();
86
- /**
87
- * Strands whose check-in is running (its timer fired, `onCheckIn` not yet returned). A
88
- * force-hibernate or untrack removes the strand, so the run's return schedules nothing: a
89
- * check-in already running is part of the chain those calls cancel.
90
- */
91
- private readonly runningCheckIns: Set<string> = new Set();
92
- /**
93
- * Strands whose timer-driven hibernate is running (`onHibernate` not yet returned). A
94
- * force-hibernate, untrack or stop removes the strand, so the return starts no check-in
95
- * chain: a force-hibernate that joins the quiesce means "keep it down".
96
- */
97
- private readonly timerHibernates: Set<string> = new Set();
98
- private running = false;
99
-
100
- constructor(config: HibernationConfig, callbacks: HibernationCallbacks) {
101
- this.config = config;
102
- this.callbacks = callbacks;
103
- log('HibernationManager created, enabled=%s', config.enabled);
104
- }
105
-
106
- /**
107
- * Get effective timeouts for a latency hint
108
- */
109
- private getTimeouts(hint: LatencyHint): HibernationTimeouts {
110
- const defaults = HIBERNATION_TIMEOUTS[hint];
111
- const custom = this.config.customTimeouts?.[hint];
112
-
113
- if (!custom) return defaults;
114
-
115
- return {
116
- idleTimeout: custom.idleTimeout ?? defaults.idleTimeout,
117
- hibernateTimeout: custom.hibernateTimeout ?? defaults.hibernateTimeout,
118
- checkInInterval: custom.checkInInterval ?? defaults.checkInInterval,
119
- checkInBackoffFactor: custom.checkInBackoffFactor ?? defaults.checkInBackoffFactor,
120
- checkInMaxInterval: custom.checkInMaxInterval ?? defaults.checkInMaxInterval
121
- };
122
- }
123
-
124
- /**
125
- * Start managing hibernation for all strands
126
- */
127
- start(): void {
128
- if (this.running) return;
129
- this.running = true;
130
- log('HibernationManager started');
131
- }
132
-
133
- /**
134
- * Stop managing hibernation
135
- */
136
- stop(): void {
137
- if (!this.running) return;
138
- this.running = false;
139
-
140
- // Clear all timers
141
- for (const timer of this.timers.values()) {
142
- clearTimeout(timer);
143
- }
144
- this.timers.clear();
145
-
146
- for (const { timer } of this.checkInTimers.values()) {
147
- clearTimeout(timer);
148
- }
149
- this.checkInTimers.clear();
150
-
151
- // In-flight wakes clean themselves up via their finally; drop the references
152
- // so a fresh start coalesces cleanly.
153
- this.wakePromises.clear();
154
- this.checkInsCancelledByWake.clear();
155
- this.probes.clear();
156
- this.runningCheckIns.clear();
157
- this.timerHibernates.clear();
158
-
159
- log('HibernationManager stopped');
160
- }
161
-
162
- /**
163
- * Whether a strand with this instance's latency hint ever hibernates — `false`
164
- * for realtime (Infinity idle timeout, see {@link HIBERNATION_TIMEOUTS}), also
165
- * honouring any per-hint `customTimeouts` override. Imperative callers
166
- * (`CadreNode.hibernateStrand` / `hibernateAll`) use this as the single source
167
- * of truth for "skip realtime", consistent with {@link trackStrand} declining
168
- * to track Infinity-timeout strands.
169
- */
170
- hibernates(instance: StrandInstance): boolean {
171
- return this.getTimeouts(instance.latencyHint).idleTimeout !== Infinity;
172
- }
173
-
174
- /**
175
- * Imperatively hibernate a tracked strand now, bypassing the idle/hibernate
176
- * timers — the mobile background-entry path. Cancels the strand's pending
177
- * idle/hibernate AND check-in timers so none can later re-fire `onHibernate`
178
- * on the already-quiesced strand or resurrect one the caller means to keep
179
- * down, then invokes `onHibernate` (quiesce + mark hibernating).
180
- *
181
- * Unlike the timer path ({@link handleHibernateTimeout}) it deliberately does
182
- * NOT re-arm the check-in chain: a force-hibernate keeps the strand down until
183
- * the caller drives the next wake on demand (push-delivered on mobile), so a
184
- * stray check-in timer must not bring it back up.
185
- *
186
- * @returns `true` if the strand was hibernated, `false` (no-op) for a realtime
187
- * strand that never hibernates.
188
- */
189
- async forceHibernate(instance: StrandInstance): Promise<boolean> {
190
- if (!this.hibernates(instance)) {
191
- log('forceHibernate: strand %s is realtime; no-op', instance.strandId);
192
- return false;
193
- }
194
- // Cancel idle/hibernate + check-in timers BEFORE quiescing so nothing fights
195
- // the imperative hibernate (a stale hibernate timer firing on the quiesced
196
- // strand, or a check-in resuming a strand the caller wants kept down) — including
197
- // a chain an in-flight wake or probe would restore when it settles, or one a
198
- // timer-driven hibernate still quiescing would start when it returns.
199
- this.clearTimers(instance.strandId);
200
- this.checkInsCancelledByWake.delete(instance.strandId);
201
- this.probes.delete(instance.strandId);
202
- this.runningCheckIns.delete(instance.strandId);
203
- this.timerHibernates.delete(instance.strandId);
204
- await this.callbacks.onHibernate(instance.strandId);
205
- log('forceHibernate: strand %s hibernated (timers cancelled, not re-armed)', instance.strandId);
206
- return true;
207
- }
208
-
209
- /**
210
- * Register a strand for hibernation management
211
- */
212
- trackStrand(instance: StrandInstance): void {
213
- if (!this.config.enabled || !this.running) return;
214
-
215
- const { strandId, latencyHint } = instance;
216
- const timeouts = this.getTimeouts(latencyHint);
217
-
218
- // Don't track strands that never hibernate
219
- if (timeouts.idleTimeout === Infinity) {
220
- log('Strand %s has realtime latency hint - no hibernation', strandId);
221
- return;
222
- }
223
-
224
- log('Tracking strand %s for hibernation (hint=%s)', strandId, latencyHint);
225
- this.scheduleIdleTransition(instance);
226
- }
227
-
228
- /**
229
- * Untrack a strand from hibernation management
230
- */
231
- untrackStrand(strandId: string): void {
232
- this.clearTimers(strandId);
233
- this.checkInsCancelledByWake.delete(strandId);
234
- this.probes.delete(strandId);
235
- this.runningCheckIns.delete(strandId);
236
- this.timerHibernates.delete(strandId);
237
- log('Untracked strand %s from hibernation', strandId);
238
- }
239
-
240
- /**
241
- * Record activity on a strand - resets idle timer
242
- */
243
- recordActivity(instance: StrandInstance): void {
244
- if (!this.config.enabled || !this.running) return;
245
-
246
- const { strandId, status, latencyHint } = instance;
247
- instance.lastActivity = new Date();
248
-
249
- // If idle, hibernating or on its way there, wake up. Coalesce so two near-simultaneous
250
- // activities don't each fire onWake (which would rebuild two libp2p nodes).
251
- if (status === 'idle' || status === 'hibernating' || this.callbacks.isQuiescing(strandId)) {
252
- log('Activity on %s strand %s - waking', status, strandId);
253
- this.clearTimersForWake(strandId);
254
- // Fire-and-forget; force-wake awaiters see errors, so swallow (and log)
255
- // here to avoid an unhandled rejection on this best-effort path. The wake's
256
- // settle re-arms the timers for the state it leaves (see beginWake).
257
- void this.beginWake(instance).catch((err) => {
258
- log('Activity-driven wake failed for strand %s: %o', strandId, err);
259
- });
260
- return;
261
- }
262
-
263
- // Reschedule idle transition if active
264
- if (status === 'active') {
265
- const timeouts = this.getTimeouts(latencyHint);
266
- if (timeouts.idleTimeout !== Infinity) {
267
- this.scheduleIdleTransition(instance);
268
- }
269
- }
270
- }
271
-
272
- /**
273
- * Force-wake a strand (an explicit or peer-sent wake). Works whether or not hibernation is
274
- * enabled; once the wake settles the strand has the timers its resulting state calls for
275
- * ({@link rearmAfterWake}).
276
- */
277
- async wakeStrand(instance: StrandInstance): Promise<void> {
278
- this.clearTimersForWake(instance.strandId);
279
- await this.beginWake(instance);
280
- }
281
-
282
- /**
283
- * Wake a strand for an on-demand probe (`CadreNode.serviceWake`), holding it until
284
- * {@link endProbe}: the probe's window runs after the wake settles and may re-hibernate the
285
- * strand, so the wake's settle arms nothing. Records whether a check-in chain was armed —
286
- * or already cancelled by a wake this one joins — so the end can restore it.
287
- */
288
- async probeWake(instance: StrandInstance): Promise<void> {
289
- const { strandId } = instance;
290
- const hadCheckInChain = this.checkInTimers.has(strandId) || this.checkInsCancelledByWake.has(strandId);
291
- this.probes.set(strandId, { hadCheckInChain });
292
- this.clearTimersForWake(strandId);
293
- await this.beginWake(instance);
294
- }
295
-
296
- /**
297
- * End a probe begun by {@link probeWake}, success or failure: arm the timers the strand's
298
- * state now calls for. A probe never creates a check-in chain, only restores one it
299
- * interrupted. No-op when the probe is no longer held (a force-hibernate or untrack during
300
- * it means "keep it down"). A wake that began during the probe's window — activity during
301
- * its closing quiesce, say — is still rebuilding the strand, so it takes the chain and its
302
- * settle decides.
303
- */
304
- endProbe(instance: StrandInstance): void {
305
- const { strandId } = instance;
306
- const probe = this.probes.get(strandId);
307
- if (!probe) return;
308
- this.probes.delete(strandId);
309
- if (this.wakePromises.has(strandId)) {
310
- if (probe.hadCheckInChain) {
311
- this.checkInsCancelledByWake.add(strandId);
312
- }
313
- return;
314
- }
315
- this.rearmAfterWake(instance, probe.hadCheckInChain);
316
- }
317
-
318
- /**
319
- * Begin a wake for a strand, or coalesce with one already in flight. Ensures
320
- * `onWake` runs at most once per concurrent wake — the returned promise is
321
- * shared by all overlapping callers and cleared once it settles. Force-wake
322
- * callers await it; activity-driven callers fire-and-forget. Success or failure,
323
- * the settle re-arms the strand's timers (unless a probe holds it), and a failure
324
- * then rejects.
325
- */
326
- private beginWake(instance: StrandInstance): Promise<void> {
327
- const { strandId } = instance;
328
- const existing = this.wakePromises.get(strandId);
329
- if (existing) {
330
- return existing;
331
- }
332
-
333
- const wake = (async () => {
334
- try {
335
- await this.callbacks.onWake(strandId);
336
- } finally {
337
- this.settleWake(instance);
338
- }
339
- })();
340
- this.wakePromises.set(strandId, wake);
341
- return wake;
342
- }
343
-
344
- private settleWake(instance: StrandInstance): void {
345
- const { strandId } = instance;
346
- const hadCheckInChain = this.checkInsCancelledByWake.delete(strandId);
347
- this.wakePromises.delete(strandId);
348
- if (!this.probes.has(strandId)) {
349
- this.rearmAfterWake(instance, hadCheckInChain);
350
- }
351
- }
352
-
353
- /**
354
- * {@link clearTimers} for a wake, first remembering an armed check-in so a wake that leaves
355
- * the strand hibernating can restore the chain. Only an ARMED chain is remembered: a strand
356
- * force-hibernated without one (the mobile background path) must not gain one from a wake,
357
- * and a check-in that is mid-run hands its chain to the wake when it returns
358
- * ({@link handCheckInChainToHolder}).
359
- */
360
- private clearTimersForWake(strandId: string): void {
361
- if (this.checkInTimers.has(strandId)) {
362
- this.checkInsCancelledByWake.add(strandId);
363
- }
364
- this.clearTimers(strandId);
365
- }
366
-
367
- /**
368
- * Leave a strand whose wake (or probe) settled with the timers its state calls for: the idle
369
- * countdown if it is live, or — if it reads `hibernating` again (a failed rebuild, or a probe
370
- * window that re-quiesced it) — the check-in chain it had before, at the base delay. A
371
- * strand that had no chain gains none, and one in any other state gets nothing.
372
- */
373
- private rearmAfterWake(instance: StrandInstance, hadCheckInChain: boolean): void {
374
- if (!this.config.enabled || !this.running) return;
375
- if (instance.status === 'hibernating') {
376
- // A hibernating strand has no idle countdown, e.g. one activity armed mid-probe.
377
- this.clearTimer(instance.strandId);
378
- if (hadCheckInChain) {
379
- log('Wake of strand %s left it hibernating; restoring its check-in chain', instance.strandId);
380
- this.scheduleCheckIn(instance);
381
- }
382
- return;
383
- }
384
- this.rearmIdleIfLive(instance);
385
- }
386
-
387
- private scheduleIdleTransition(instance: StrandInstance): void {
388
- const { strandId, latencyHint } = instance;
389
- const timeouts = this.getTimeouts(latencyHint);
390
-
391
- // Clear existing timer
392
- this.clearTimer(strandId);
393
-
394
- // Schedule idle transition
395
- const timer = setTimeout(() => {
396
- this.handleIdleTimeout(instance);
397
- }, timeouts.idleTimeout);
398
-
399
- this.timers.set(strandId, timer);
400
- }
401
-
402
- private handleIdleTimeout(instance: StrandInstance): void {
403
- const { strandId, latencyHint } = instance;
404
-
405
- if (!this.running) return;
406
-
407
- log('Idle timeout for strand %s', strandId);
408
-
409
- // Transition to idle. The callback can reject (e.g. a future idle handler
410
- // that releases resources); catch so the timer chain never unhandled-rejects.
411
- void this.callbacks.onIdle(strandId).then(() => {
412
- // Schedule hibernate transition
413
- const timeouts = this.getTimeouts(latencyHint);
414
- if (timeouts.hibernateTimeout !== Infinity) {
415
- this.scheduleHibernateTransition(instance);
416
- }
417
- }).catch((err) => {
418
- log('onIdle failed for strand %s: %o', strandId, err);
419
- });
420
- }
421
-
422
- private scheduleHibernateTransition(instance: StrandInstance): void {
423
- const { strandId, latencyHint } = instance;
424
- const timeouts = this.getTimeouts(latencyHint);
425
-
426
- // Clear existing timer
427
- this.clearTimer(strandId);
428
-
429
- // Schedule hibernate transition
430
- const timer = setTimeout(() => {
431
- this.handleHibernateTimeout(instance);
432
- }, timeouts.hibernateTimeout);
433
-
434
- this.timers.set(strandId, timer);
435
- }
436
-
437
- private handleHibernateTimeout(instance: StrandInstance): void {
438
- const { strandId, latencyHint } = instance;
439
-
440
- if (!this.running) return;
441
-
442
- log('Hibernate timeout for strand %s', strandId);
443
-
444
- // Transition to hibernating. onHibernate now releases strand-network
445
- // resources (quiesce), so its close()/stop() can reject — catch so a failed
446
- // hibernate logs instead of producing an unhandled rejection.
447
- this.timerHibernates.add(strandId);
448
- void this.callbacks.onHibernate(strandId).then(() => {
449
- if (!this.timerHibernates.delete(strandId)) {
450
- log('Hibernate of strand %s was force-hibernated or untracked meanwhile; no check-in chain', strandId);
451
- return;
452
- }
453
- if (this.getTimeouts(latencyHint).checkInInterval !== Infinity) {
454
- this.startCheckInChain(instance);
455
- }
456
- }).catch((err) => {
457
- this.timerHibernates.delete(strandId);
458
- log('onHibernate failed for strand %s: %o', strandId, err);
459
- });
460
- }
461
-
462
- /**
463
- * Start the check-in chain of a strand the timer path just hibernated. A wake or probe that
464
- * landed during the quiesce holds the strand and takes the chain instead
465
- * ({@link handCheckInChainToHolder}); one that already settled left it live with its idle
466
- * countdown, and a live strand gets no chain.
467
- */
468
- private startCheckInChain(instance: StrandInstance): void {
469
- if (this.handCheckInChainToHolder(instance)) return;
470
- if (instance.status === 'hibernating') {
471
- this.scheduleCheckIn(instance);
472
- }
473
- }
474
-
475
- /**
476
- * Arm the next check-in for a hibernating strand. Each call is a single-shot
477
- * `setTimeout` (not a fixed `setInterval`) so the period escalates per
478
- * {@link runCheckIn} and a slow `onCheckIn` never overlaps the next tick.
479
- *
480
- * `delay` is omitted by a chain start ({@link startCheckInChain}, or a wake restoring one),
481
- * defaulting to the base `checkInInterval` — which is why backoff naturally
482
- * resets to base each fresh hibernation cycle, with no per-strand counter to
483
- * clear on wake. Subsequent ticks pass the escalated, capped delay.
484
- */
485
- private scheduleCheckIn(instance: StrandInstance, delay?: number): void {
486
- const { strandId, latencyHint } = instance;
487
- const timeouts = this.getTimeouts(latencyHint);
488
- const currentDelay = delay ?? timeouts.checkInInterval;
489
-
490
- // Replace any existing check-in timer.
491
- this.clearCheckInTimer(strandId);
492
-
493
- const timer = setTimeout(() => {
494
- // Only ARMED check-ins stay in the map, so a wake during this run does not take it
495
- // for a chain to restore (see clearTimersForWake) — the run hands it over on return.
496
- this.checkInTimers.delete(strandId);
497
- void this.runCheckIn(instance, currentDelay);
498
- }, currentDelay);
499
-
500
- this.checkInTimers.set(strandId, { timer, instance });
501
- instance.nextCheckIn = new Date(Date.now() + currentDelay);
502
- }
503
-
504
- /**
505
- * Run a single check-in tick: invoke `onCheckIn` (a real resume → bounded
506
- * sync window → re-hibernate-if-idle cycle in `CadreNode`) and AWAIT it before
507
- * deciding the next step.
508
- *
509
- * - If a force-hibernate, untrack or stop cancelled the chain while it ran, stop.
510
- * - If a wake or probe holds the strand, hand the chain to it
511
- * ({@link handCheckInChainToHolder}).
512
- * - If the strand woke during the check-in (`onCheckIn` left it non-
513
- * `hibernating`), stop the chain and restart the idle countdown; the next
514
- * hibernation restarts the chain at the base delay (backoff reset).
515
- * - Otherwise escalate the delay by `checkInBackoffFactor`, capped at
516
- * `checkInMaxInterval`, and reschedule.
517
- */
518
- private async runCheckIn(instance: StrandInstance, currentDelay: number): Promise<void> {
519
- const { strandId, latencyHint } = instance;
520
- if (!this.running) return;
521
-
522
- log('Check-in for hibernating strand %s (delay=%dms)', strandId, currentDelay);
523
- this.runningCheckIns.add(strandId);
524
-
525
- try {
526
- await this.callbacks.onCheckIn(strandId);
527
- } catch (err) {
528
- // A failed check-in (e.g. resume threw) must not break the chain — log and
529
- // fall through to reschedule the next, longer-delayed attempt.
530
- log('onCheckIn failed for strand %s: %o', strandId, err);
531
- }
532
-
533
- if (!this.runningCheckIns.delete(strandId) || !this.running) {
534
- log('Check-in chain of strand %s was cancelled while it ran; not rescheduling', strandId);
535
- instance.nextCheckIn = undefined;
536
- return;
537
- }
538
- if (this.handCheckInChainToHolder(instance)) return;
539
-
540
- // The check-in either woke the strand (CadreNode left it active) or left it
541
- // hibernating. Inspect the shared instance the callback just mutated.
542
- if (instance.status !== 'hibernating') {
543
- log('Check-in woke strand %s; backoff resets on next hibernation', strandId);
544
- this.clearCheckInTimer(strandId);
545
- // The chain is stopping; drop the now-stale next-check-in advertisement so
546
- // `getStrand` doesn't report a phantom check-in for a strand that is awake.
547
- instance.nextCheckIn = undefined;
548
- this.rearmIdleIfLive(instance);
549
- return;
550
- }
551
-
552
- const timeouts = this.getTimeouts(latencyHint);
553
- const nextDelay = Math.min(
554
- currentDelay * timeouts.checkInBackoffFactor,
555
- timeouts.checkInMaxInterval
556
- );
557
- this.scheduleCheckIn(instance, nextDelay);
558
- }
559
-
560
- /**
561
- * Give a check-in chain — a returning check-in's, or the one the timer path starts
562
- * ({@link startCheckInChain}) — to a wake or probe that holds the strand, as if that
563
- * wake had cancelled an armed check-in: its settle ({@link settleWake}) or end
564
- * ({@link endProbe}) restores the chain at the base delay if the strand ends `hibernating`,
565
- * and starts the idle countdown if it ends live. The status the check-in left says nothing
566
- * yet: a check-in that failed before its rebuild leaves the strand to a wake still building
567
- * it (`starting`), and one that ends `hibernating` may be overtaken by a wake still in
568
- * flight. Deciding here would drop the chain if that wake then fails, or leave a check-in
569
- * armed on a strand it brings up.
570
- *
571
- * @returns whether a wake or probe took the chain.
572
- */
573
- private handCheckInChainToHolder(instance: StrandInstance): boolean {
574
- const { strandId } = instance;
575
- const probe = this.probes.get(strandId);
576
- if (probe) {
577
- probe.hadCheckInChain = true;
578
- } else if (this.wakePromises.has(strandId)) {
579
- this.checkInsCancelledByWake.add(strandId);
580
- } else {
581
- return false;
582
- }
583
- log('Strand %s: a wake holds it, so the wake decides its check-in chain', strandId);
584
- this.clearCheckInTimer(strandId);
585
- instance.nextCheckIn = undefined;
586
- return true;
587
- }
588
-
589
- /**
590
- * Start the idle countdown for a strand a check-in or wake left live. Activity recorded
591
- * while the runtime was still rebuilding found the strand neither idle nor active, so it
592
- * armed nothing — without this the strand would stay up until the next activity. Only for
593
- * a live status: a strand stopped or failed mid-rebuild must not gain a timer chain. Nor
594
- * one being quiesced: it reads live until the quiesce ends, e.g. a force-hibernate queued
595
- * behind the rebuild of the wake settling now, and the countdown would later idle the
596
- * quiesced strand and start a check-in chain the caller meant to keep off.
597
- */
598
- private rearmIdleIfLive(instance: StrandInstance): void {
599
- const live = (instance.status === 'active' || instance.status === 'syncing')
600
- && !this.callbacks.isQuiescing(instance.strandId);
601
- if (live && this.getTimeouts(instance.latencyHint).idleTimeout !== Infinity) {
602
- this.scheduleIdleTransition(instance);
603
- }
604
- }
605
-
606
- private clearTimer(strandId: string): void {
607
- const timer = this.timers.get(strandId);
608
- if (timer) {
609
- clearTimeout(timer);
610
- this.timers.delete(strandId);
611
- }
612
- }
613
-
614
- private clearCheckInTimer(strandId: string): void {
615
- const pending = this.checkInTimers.get(strandId);
616
- if (pending) {
617
- clearTimeout(pending.timer);
618
- this.checkInTimers.delete(strandId);
619
- // `getStrand` must not advertise a check-in that is no longer scheduled.
620
- pending.instance.nextCheckIn = undefined;
621
- }
622
- }
623
-
624
- private clearTimers(strandId: string): void {
625
- this.clearTimer(strandId);
626
- this.clearCheckInTimer(strandId);
627
- }
628
-
629
- /**
630
- * Get the current status of hibernation tracking
631
- */
632
- getStatus(): { enabled: boolean; trackedStrands: number } {
633
- return {
634
- enabled: this.config.enabled && this.running,
635
- trackedStrands: this.timers.size + this.checkInTimers.size
636
- };
637
- }
638
- }
1
+ import debug from 'debug';
2
+ import type {
3
+ StrandInstance,
4
+ LatencyHint,
5
+ HibernationTimeouts,
6
+ HibernationConfig
7
+ } from './types.js';
8
+ import { HIBERNATION_TIMEOUTS } from './types.js';
9
+
10
+ const log = debug('sereus:cadre:hibernation');
11
+
12
+ /** An armed check-in: its timer, and the instance the chain reschedules. */
13
+ interface PendingCheckIn {
14
+ timer: ReturnType<typeof setTimeout>;
15
+ instance: StrandInstance;
16
+ }
17
+
18
+ /**
19
+ * Callbacks for hibernation state changes
20
+ */
21
+ export interface HibernationCallbacks {
22
+ onIdle: (strandId: string) => Promise<void>;
23
+ onHibernate: (strandId: string) => Promise<void>;
24
+ onWake: (strandId: string) => Promise<void>;
25
+ /**
26
+ * Perform a real cohort check-in for a hibernating strand. The implementation
27
+ * (`CadreNode.handleStrandCheckIn`) resumes the strand, gives it a bounded
28
+ * window to connect to reachable cohort peers and surface pending activity,
29
+ * then re-hibernates if still idle. The manager AWAITS this before scheduling
30
+ * the next (longer-delayed) check-in, so a slow check-in never overlaps the
31
+ * next tick. After it resolves the manager inspects `instance.status`: a
32
+ * strand left non-`hibernating` is treated as woken (backoff resets on the
33
+ * next hibernation); a strand left `hibernating` escalates the backoff. While
34
+ * a wake or probe holds the strand, the chain passes to it instead.
35
+ */
36
+ onCheckIn: (strandId: string) => Promise<void>;
37
+ /**
38
+ * Whether the strand's runtime is being released right now
39
+ * (`StrandInstanceManager.isQuiescing`): it may still read live, but it is on its way to
40
+ * `hibernating`, so activity on it wakes it as activity on a hibernating strand does.
41
+ */
42
+ isQuiescing: (strandId: string) => boolean;
43
+ }
44
+
45
+ /**
46
+ * Manages strand hibernation state transitions based on activity.
47
+ *
48
+ * State machine:
49
+ * active → idle (after idleTimeout with no activity)
50
+ * idle → hibernating (after hibernateTimeout with no activity)
51
+ * idle → active (on activity)
52
+ * hibernating → active (on wake signal or check-in with pending activity)
53
+ */
54
+ export class HibernationManager {
55
+ private readonly config: HibernationConfig;
56
+ private readonly callbacks: HibernationCallbacks;
57
+ private readonly timers: Map<string, ReturnType<typeof setTimeout>> = new Map();
58
+ /**
59
+ * Pending check-in timers, one per hibernating strand. Unlike the old fixed
60
+ * `setInterval`, these are single-shot `setTimeout`s rescheduled by
61
+ * {@link runCheckIn} with an escalating delay — so a long-running `onCheckIn`
62
+ * can never overlap the next tick, and the period adapts to the backoff.
63
+ */
64
+ private readonly checkInTimers: Map<string, PendingCheckIn> = new Map();
65
+ /**
66
+ * In-flight wake promises keyed by strandId. Coalesces overlapping wake
67
+ * triggers (two near-simultaneous activities, or activity racing a force wake)
68
+ * so `onWake` — and the libp2p-node rebuild it drives — runs at most once per
69
+ * concurrent wake.
70
+ */
71
+ private readonly wakePromises: Map<string, Promise<void>> = new Map();
72
+ /**
73
+ * Hibernating strands whose armed check-in a wake cancelled, held until that wake
74
+ * settles. A wake that leaves the strand hibernating again (a failed rebuild, or a probe's
75
+ * window that re-quiesced it) re-arms the chain from here ({@link rearmAfterWake}); without
76
+ * it the strand is left with no runtime and nothing scheduled to retry.
77
+ */
78
+ private readonly checkInsCancelledByWake: Set<string> = new Set();
79
+ /**
80
+ * Strands an on-demand probe (`CadreNode.serviceWake`) holds from its wake to the end of its
81
+ * window, with whether a check-in chain was armed when it began. While held, the probe's end
82
+ * ({@link endProbe}) — not the wake's settle — sets the timers, since the window may
83
+ * re-hibernate the strand after the wake settled.
84
+ */
85
+ private readonly probes: Map<string, { hadCheckInChain: boolean }> = new Map();
86
+ /**
87
+ * Strands whose check-in is running (its timer fired, `onCheckIn` not yet returned). A
88
+ * force-hibernate or untrack removes the strand, so the run's return schedules nothing: a
89
+ * check-in already running is part of the chain those calls cancel.
90
+ */
91
+ private readonly runningCheckIns: Set<string> = new Set();
92
+ /**
93
+ * Strands whose timer-driven hibernate is running (`onHibernate` not yet returned). A
94
+ * force-hibernate, untrack or stop removes the strand, so the return starts no check-in
95
+ * chain: a force-hibernate that joins the quiesce means "keep it down".
96
+ */
97
+ private readonly timerHibernates: Set<string> = new Set();
98
+ private running = false;
99
+
100
+ constructor(config: HibernationConfig, callbacks: HibernationCallbacks) {
101
+ this.config = config;
102
+ this.callbacks = callbacks;
103
+ log('HibernationManager created, enabled=%s', config.enabled);
104
+ }
105
+
106
+ /**
107
+ * Get effective timeouts for a latency hint
108
+ */
109
+ private getTimeouts(hint: LatencyHint): HibernationTimeouts {
110
+ const defaults = HIBERNATION_TIMEOUTS[hint];
111
+ const custom = this.config.customTimeouts?.[hint];
112
+
113
+ if (!custom) return defaults;
114
+
115
+ return {
116
+ idleTimeout: custom.idleTimeout ?? defaults.idleTimeout,
117
+ hibernateTimeout: custom.hibernateTimeout ?? defaults.hibernateTimeout,
118
+ checkInInterval: custom.checkInInterval ?? defaults.checkInInterval,
119
+ checkInBackoffFactor: custom.checkInBackoffFactor ?? defaults.checkInBackoffFactor,
120
+ checkInMaxInterval: custom.checkInMaxInterval ?? defaults.checkInMaxInterval
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Start managing hibernation for all strands
126
+ */
127
+ start(): void {
128
+ if (this.running) return;
129
+ this.running = true;
130
+ log('HibernationManager started');
131
+ }
132
+
133
+ /**
134
+ * Stop managing hibernation
135
+ */
136
+ stop(): void {
137
+ if (!this.running) return;
138
+ this.running = false;
139
+
140
+ // Clear all timers
141
+ for (const timer of this.timers.values()) {
142
+ clearTimeout(timer);
143
+ }
144
+ this.timers.clear();
145
+
146
+ for (const { timer } of this.checkInTimers.values()) {
147
+ clearTimeout(timer);
148
+ }
149
+ this.checkInTimers.clear();
150
+
151
+ // In-flight wakes clean themselves up via their finally; drop the references
152
+ // so a fresh start coalesces cleanly.
153
+ this.wakePromises.clear();
154
+ this.checkInsCancelledByWake.clear();
155
+ this.probes.clear();
156
+ this.runningCheckIns.clear();
157
+ this.timerHibernates.clear();
158
+
159
+ log('HibernationManager stopped');
160
+ }
161
+
162
+ /**
163
+ * Whether a strand with this instance's latency hint ever hibernates — `false`
164
+ * for realtime (Infinity idle timeout, see {@link HIBERNATION_TIMEOUTS}), also
165
+ * honouring any per-hint `customTimeouts` override. Imperative callers
166
+ * (`CadreNode.hibernateStrand` / `hibernateAll`) use this as the single source
167
+ * of truth for "skip realtime", consistent with {@link trackStrand} declining
168
+ * to track Infinity-timeout strands.
169
+ */
170
+ hibernates(instance: StrandInstance): boolean {
171
+ return this.getTimeouts(instance.latencyHint).idleTimeout !== Infinity;
172
+ }
173
+
174
+ /**
175
+ * Imperatively hibernate a tracked strand now, bypassing the idle/hibernate
176
+ * timers — the mobile background-entry path. Cancels the strand's pending
177
+ * idle/hibernate AND check-in timers so none can later re-fire `onHibernate`
178
+ * on the already-quiesced strand or resurrect one the caller means to keep
179
+ * down, then invokes `onHibernate` (quiesce + mark hibernating).
180
+ *
181
+ * Unlike the timer path ({@link handleHibernateTimeout}) it deliberately does
182
+ * NOT re-arm the check-in chain: a force-hibernate keeps the strand down until
183
+ * the caller drives the next wake on demand (push-delivered on mobile), so a
184
+ * stray check-in timer must not bring it back up.
185
+ *
186
+ * @returns `true` if the strand was hibernated, `false` (no-op) for a realtime
187
+ * strand that never hibernates.
188
+ */
189
+ async forceHibernate(instance: StrandInstance): Promise<boolean> {
190
+ if (!this.hibernates(instance)) {
191
+ log('forceHibernate: strand %s is realtime; no-op', instance.strandId);
192
+ return false;
193
+ }
194
+ // Cancel idle/hibernate + check-in timers BEFORE quiescing so nothing fights
195
+ // the imperative hibernate (a stale hibernate timer firing on the quiesced
196
+ // strand, or a check-in resuming a strand the caller wants kept down) — including
197
+ // a chain an in-flight wake or probe would restore when it settles, or one a
198
+ // timer-driven hibernate still quiescing would start when it returns.
199
+ this.clearTimers(instance.strandId);
200
+ this.checkInsCancelledByWake.delete(instance.strandId);
201
+ this.probes.delete(instance.strandId);
202
+ this.runningCheckIns.delete(instance.strandId);
203
+ this.timerHibernates.delete(instance.strandId);
204
+ await this.callbacks.onHibernate(instance.strandId);
205
+ log('forceHibernate: strand %s hibernated (timers cancelled, not re-armed)', instance.strandId);
206
+ return true;
207
+ }
208
+
209
+ /**
210
+ * Register a strand for hibernation management
211
+ */
212
+ trackStrand(instance: StrandInstance): void {
213
+ if (!this.config.enabled || !this.running) return;
214
+
215
+ const { strandId, latencyHint } = instance;
216
+ const timeouts = this.getTimeouts(latencyHint);
217
+
218
+ // Don't track strands that never hibernate
219
+ if (timeouts.idleTimeout === Infinity) {
220
+ log('Strand %s has realtime latency hint - no hibernation', strandId);
221
+ return;
222
+ }
223
+
224
+ log('Tracking strand %s for hibernation (hint=%s)', strandId, latencyHint);
225
+ this.scheduleIdleTransition(instance);
226
+ }
227
+
228
+ /**
229
+ * Untrack a strand from hibernation management
230
+ */
231
+ untrackStrand(strandId: string): void {
232
+ this.clearTimers(strandId);
233
+ this.checkInsCancelledByWake.delete(strandId);
234
+ this.probes.delete(strandId);
235
+ this.runningCheckIns.delete(strandId);
236
+ this.timerHibernates.delete(strandId);
237
+ log('Untracked strand %s from hibernation', strandId);
238
+ }
239
+
240
+ /**
241
+ * Record activity on a strand - resets idle timer
242
+ */
243
+ recordActivity(instance: StrandInstance): void {
244
+ if (!this.config.enabled || !this.running) return;
245
+
246
+ const { strandId, status, latencyHint } = instance;
247
+ instance.lastActivity = new Date();
248
+
249
+ // If idle, hibernating or on its way there, wake up. Coalesce so two near-simultaneous
250
+ // activities don't each fire onWake (which would rebuild two libp2p nodes).
251
+ if (status === 'idle' || status === 'hibernating' || this.callbacks.isQuiescing(strandId)) {
252
+ log('Activity on %s strand %s - waking', status, strandId);
253
+ this.clearTimersForWake(strandId);
254
+ // Fire-and-forget; force-wake awaiters see errors, so swallow (and log)
255
+ // here to avoid an unhandled rejection on this best-effort path. The wake's
256
+ // settle re-arms the timers for the state it leaves (see beginWake).
257
+ void this.beginWake(instance).catch((err) => {
258
+ log('Activity-driven wake failed for strand %s: %o', strandId, err);
259
+ });
260
+ return;
261
+ }
262
+
263
+ // Reschedule idle transition if active
264
+ if (status === 'active') {
265
+ const timeouts = this.getTimeouts(latencyHint);
266
+ if (timeouts.idleTimeout !== Infinity) {
267
+ this.scheduleIdleTransition(instance);
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Force-wake a strand (an explicit or peer-sent wake). Works whether or not hibernation is
274
+ * enabled; once the wake settles the strand has the timers its resulting state calls for
275
+ * ({@link rearmAfterWake}).
276
+ */
277
+ async wakeStrand(instance: StrandInstance): Promise<void> {
278
+ this.clearTimersForWake(instance.strandId);
279
+ await this.beginWake(instance);
280
+ }
281
+
282
+ /**
283
+ * Wake a strand for an on-demand probe (`CadreNode.serviceWake`), holding it until
284
+ * {@link endProbe}: the probe's window runs after the wake settles and may re-hibernate the
285
+ * strand, so the wake's settle arms nothing. Records whether a check-in chain was armed —
286
+ * or already cancelled by a wake this one joins — so the end can restore it.
287
+ */
288
+ async probeWake(instance: StrandInstance): Promise<void> {
289
+ const { strandId } = instance;
290
+ const hadCheckInChain = this.checkInTimers.has(strandId) || this.checkInsCancelledByWake.has(strandId);
291
+ this.probes.set(strandId, { hadCheckInChain });
292
+ this.clearTimersForWake(strandId);
293
+ await this.beginWake(instance);
294
+ }
295
+
296
+ /**
297
+ * End a probe begun by {@link probeWake}, success or failure: arm the timers the strand's
298
+ * state now calls for. A probe never creates a check-in chain, only restores one it
299
+ * interrupted. No-op when the probe is no longer held (a force-hibernate or untrack during
300
+ * it means "keep it down"). A wake that began during the probe's window — activity during
301
+ * its closing quiesce, say — is still rebuilding the strand, so it takes the chain and its
302
+ * settle decides.
303
+ */
304
+ endProbe(instance: StrandInstance): void {
305
+ const { strandId } = instance;
306
+ const probe = this.probes.get(strandId);
307
+ if (!probe) return;
308
+ this.probes.delete(strandId);
309
+ if (this.wakePromises.has(strandId)) {
310
+ if (probe.hadCheckInChain) {
311
+ this.checkInsCancelledByWake.add(strandId);
312
+ }
313
+ return;
314
+ }
315
+ this.rearmAfterWake(instance, probe.hadCheckInChain);
316
+ }
317
+
318
+ /**
319
+ * Begin a wake for a strand, or coalesce with one already in flight. Ensures
320
+ * `onWake` runs at most once per concurrent wake — the returned promise is
321
+ * shared by all overlapping callers and cleared once it settles. Force-wake
322
+ * callers await it; activity-driven callers fire-and-forget. Success or failure,
323
+ * the settle re-arms the strand's timers (unless a probe holds it), and a failure
324
+ * then rejects.
325
+ */
326
+ private beginWake(instance: StrandInstance): Promise<void> {
327
+ const { strandId } = instance;
328
+ const existing = this.wakePromises.get(strandId);
329
+ if (existing) {
330
+ return existing;
331
+ }
332
+
333
+ const wake = (async () => {
334
+ try {
335
+ await this.callbacks.onWake(strandId);
336
+ } finally {
337
+ this.settleWake(instance);
338
+ }
339
+ })();
340
+ this.wakePromises.set(strandId, wake);
341
+ return wake;
342
+ }
343
+
344
+ private settleWake(instance: StrandInstance): void {
345
+ const { strandId } = instance;
346
+ const hadCheckInChain = this.checkInsCancelledByWake.delete(strandId);
347
+ this.wakePromises.delete(strandId);
348
+ if (!this.probes.has(strandId)) {
349
+ this.rearmAfterWake(instance, hadCheckInChain);
350
+ }
351
+ }
352
+
353
+ /**
354
+ * {@link clearTimers} for a wake, first remembering an armed check-in so a wake that leaves
355
+ * the strand hibernating can restore the chain. Only an ARMED chain is remembered: a strand
356
+ * force-hibernated without one (the mobile background path) must not gain one from a wake,
357
+ * and a check-in that is mid-run hands its chain to the wake when it returns
358
+ * ({@link handCheckInChainToHolder}).
359
+ */
360
+ private clearTimersForWake(strandId: string): void {
361
+ if (this.checkInTimers.has(strandId)) {
362
+ this.checkInsCancelledByWake.add(strandId);
363
+ }
364
+ this.clearTimers(strandId);
365
+ }
366
+
367
+ /**
368
+ * Leave a strand whose wake (or probe) settled with the timers its state calls for: the idle
369
+ * countdown if it is live, or — if it reads `hibernating` again (a failed rebuild, or a probe
370
+ * window that re-quiesced it) — the check-in chain it had before, at the base delay. A
371
+ * strand that had no chain gains none, and one in any other state gets nothing.
372
+ */
373
+ private rearmAfterWake(instance: StrandInstance, hadCheckInChain: boolean): void {
374
+ if (!this.config.enabled || !this.running) return;
375
+ if (instance.status === 'hibernating') {
376
+ // A hibernating strand has no idle countdown, e.g. one activity armed mid-probe.
377
+ this.clearTimer(instance.strandId);
378
+ if (hadCheckInChain) {
379
+ log('Wake of strand %s left it hibernating; restoring its check-in chain', instance.strandId);
380
+ this.scheduleCheckIn(instance);
381
+ }
382
+ return;
383
+ }
384
+ this.rearmIdleIfLive(instance);
385
+ }
386
+
387
+ private scheduleIdleTransition(instance: StrandInstance): void {
388
+ const { strandId, latencyHint } = instance;
389
+ const timeouts = this.getTimeouts(latencyHint);
390
+
391
+ // Clear existing timer
392
+ this.clearTimer(strandId);
393
+
394
+ // Schedule idle transition
395
+ const timer = setTimeout(() => {
396
+ this.handleIdleTimeout(instance);
397
+ }, timeouts.idleTimeout);
398
+
399
+ this.timers.set(strandId, timer);
400
+ }
401
+
402
+ private handleIdleTimeout(instance: StrandInstance): void {
403
+ const { strandId, latencyHint } = instance;
404
+
405
+ if (!this.running) return;
406
+
407
+ log('Idle timeout for strand %s', strandId);
408
+
409
+ // Transition to idle. The callback can reject (e.g. a future idle handler
410
+ // that releases resources); catch so the timer chain never unhandled-rejects.
411
+ void this.callbacks.onIdle(strandId).then(() => {
412
+ // Schedule hibernate transition
413
+ const timeouts = this.getTimeouts(latencyHint);
414
+ if (timeouts.hibernateTimeout !== Infinity) {
415
+ this.scheduleHibernateTransition(instance);
416
+ }
417
+ }).catch((err) => {
418
+ log('onIdle failed for strand %s: %o', strandId, err);
419
+ });
420
+ }
421
+
422
+ private scheduleHibernateTransition(instance: StrandInstance): void {
423
+ const { strandId, latencyHint } = instance;
424
+ const timeouts = this.getTimeouts(latencyHint);
425
+
426
+ // Clear existing timer
427
+ this.clearTimer(strandId);
428
+
429
+ // Schedule hibernate transition
430
+ const timer = setTimeout(() => {
431
+ this.handleHibernateTimeout(instance);
432
+ }, timeouts.hibernateTimeout);
433
+
434
+ this.timers.set(strandId, timer);
435
+ }
436
+
437
+ private handleHibernateTimeout(instance: StrandInstance): void {
438
+ const { strandId, latencyHint } = instance;
439
+
440
+ if (!this.running) return;
441
+
442
+ log('Hibernate timeout for strand %s', strandId);
443
+
444
+ // Transition to hibernating. onHibernate now releases strand-network
445
+ // resources (quiesce), so its close()/stop() can reject — catch so a failed
446
+ // hibernate logs instead of producing an unhandled rejection.
447
+ this.timerHibernates.add(strandId);
448
+ void this.callbacks.onHibernate(strandId).then(() => {
449
+ if (!this.timerHibernates.delete(strandId)) {
450
+ log('Hibernate of strand %s was force-hibernated or untracked meanwhile; no check-in chain', strandId);
451
+ return;
452
+ }
453
+ if (this.getTimeouts(latencyHint).checkInInterval !== Infinity) {
454
+ this.startCheckInChain(instance);
455
+ }
456
+ }).catch((err) => {
457
+ this.timerHibernates.delete(strandId);
458
+ log('onHibernate failed for strand %s: %o', strandId, err);
459
+ });
460
+ }
461
+
462
+ /**
463
+ * Start the check-in chain of a strand the timer path just hibernated. A wake or probe that
464
+ * landed during the quiesce holds the strand and takes the chain instead
465
+ * ({@link handCheckInChainToHolder}); one that already settled left it live with its idle
466
+ * countdown, and a live strand gets no chain.
467
+ */
468
+ private startCheckInChain(instance: StrandInstance): void {
469
+ if (this.handCheckInChainToHolder(instance)) return;
470
+ if (instance.status === 'hibernating') {
471
+ this.scheduleCheckIn(instance);
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Arm the next check-in for a hibernating strand. Each call is a single-shot
477
+ * `setTimeout` (not a fixed `setInterval`) so the period escalates per
478
+ * {@link runCheckIn} and a slow `onCheckIn` never overlaps the next tick.
479
+ *
480
+ * `delay` is omitted by a chain start ({@link startCheckInChain}, or a wake restoring one),
481
+ * defaulting to the base `checkInInterval` — which is why backoff naturally
482
+ * resets to base each fresh hibernation cycle, with no per-strand counter to
483
+ * clear on wake. Subsequent ticks pass the escalated, capped delay.
484
+ */
485
+ private scheduleCheckIn(instance: StrandInstance, delay?: number): void {
486
+ const { strandId, latencyHint } = instance;
487
+ const timeouts = this.getTimeouts(latencyHint);
488
+ const currentDelay = delay ?? timeouts.checkInInterval;
489
+
490
+ // Replace any existing check-in timer.
491
+ this.clearCheckInTimer(strandId);
492
+
493
+ const timer = setTimeout(() => {
494
+ // Only ARMED check-ins stay in the map, so a wake during this run does not take it
495
+ // for a chain to restore (see clearTimersForWake) — the run hands it over on return.
496
+ this.checkInTimers.delete(strandId);
497
+ void this.runCheckIn(instance, currentDelay);
498
+ }, currentDelay);
499
+
500
+ this.checkInTimers.set(strandId, { timer, instance });
501
+ instance.nextCheckIn = new Date(Date.now() + currentDelay);
502
+ }
503
+
504
+ /**
505
+ * Run a single check-in tick: invoke `onCheckIn` (a real resume → bounded
506
+ * sync window → re-hibernate-if-idle cycle in `CadreNode`) and AWAIT it before
507
+ * deciding the next step.
508
+ *
509
+ * - If a force-hibernate, untrack or stop cancelled the chain while it ran, stop.
510
+ * - If a wake or probe holds the strand, hand the chain to it
511
+ * ({@link handCheckInChainToHolder}).
512
+ * - If the strand woke during the check-in (`onCheckIn` left it non-
513
+ * `hibernating`), stop the chain and restart the idle countdown; the next
514
+ * hibernation restarts the chain at the base delay (backoff reset).
515
+ * - Otherwise escalate the delay by `checkInBackoffFactor`, capped at
516
+ * `checkInMaxInterval`, and reschedule.
517
+ */
518
+ private async runCheckIn(instance: StrandInstance, currentDelay: number): Promise<void> {
519
+ const { strandId, latencyHint } = instance;
520
+ if (!this.running) return;
521
+
522
+ log('Check-in for hibernating strand %s (delay=%dms)', strandId, currentDelay);
523
+ this.runningCheckIns.add(strandId);
524
+
525
+ try {
526
+ await this.callbacks.onCheckIn(strandId);
527
+ } catch (err) {
528
+ // A failed check-in (e.g. resume threw) must not break the chain — log and
529
+ // fall through to reschedule the next, longer-delayed attempt.
530
+ log('onCheckIn failed for strand %s: %o', strandId, err);
531
+ }
532
+
533
+ if (!this.runningCheckIns.delete(strandId) || !this.running) {
534
+ log('Check-in chain of strand %s was cancelled while it ran; not rescheduling', strandId);
535
+ instance.nextCheckIn = undefined;
536
+ return;
537
+ }
538
+ if (this.handCheckInChainToHolder(instance)) return;
539
+
540
+ // The check-in either woke the strand (CadreNode left it active) or left it
541
+ // hibernating. Inspect the shared instance the callback just mutated.
542
+ if (instance.status !== 'hibernating') {
543
+ log('Check-in woke strand %s; backoff resets on next hibernation', strandId);
544
+ this.clearCheckInTimer(strandId);
545
+ // The chain is stopping; drop the now-stale next-check-in advertisement so
546
+ // `getStrand` doesn't report a phantom check-in for a strand that is awake.
547
+ instance.nextCheckIn = undefined;
548
+ this.rearmIdleIfLive(instance);
549
+ return;
550
+ }
551
+
552
+ const timeouts = this.getTimeouts(latencyHint);
553
+ const nextDelay = Math.min(
554
+ currentDelay * timeouts.checkInBackoffFactor,
555
+ timeouts.checkInMaxInterval
556
+ );
557
+ this.scheduleCheckIn(instance, nextDelay);
558
+ }
559
+
560
+ /**
561
+ * Give a check-in chain — a returning check-in's, or the one the timer path starts
562
+ * ({@link startCheckInChain}) — to a wake or probe that holds the strand, as if that
563
+ * wake had cancelled an armed check-in: its settle ({@link settleWake}) or end
564
+ * ({@link endProbe}) restores the chain at the base delay if the strand ends `hibernating`,
565
+ * and starts the idle countdown if it ends live. The status the check-in left says nothing
566
+ * yet: a check-in that failed before its rebuild leaves the strand to a wake still building
567
+ * it (`starting`), and one that ends `hibernating` may be overtaken by a wake still in
568
+ * flight. Deciding here would drop the chain if that wake then fails, or leave a check-in
569
+ * armed on a strand it brings up.
570
+ *
571
+ * @returns whether a wake or probe took the chain.
572
+ */
573
+ private handCheckInChainToHolder(instance: StrandInstance): boolean {
574
+ const { strandId } = instance;
575
+ const probe = this.probes.get(strandId);
576
+ if (probe) {
577
+ probe.hadCheckInChain = true;
578
+ } else if (this.wakePromises.has(strandId)) {
579
+ this.checkInsCancelledByWake.add(strandId);
580
+ } else {
581
+ return false;
582
+ }
583
+ log('Strand %s: a wake holds it, so the wake decides its check-in chain', strandId);
584
+ this.clearCheckInTimer(strandId);
585
+ instance.nextCheckIn = undefined;
586
+ return true;
587
+ }
588
+
589
+ /**
590
+ * Start the idle countdown for a strand a check-in or wake left live. Activity recorded
591
+ * while the runtime was still rebuilding found the strand neither idle nor active, so it
592
+ * armed nothing — without this the strand would stay up until the next activity. Only for
593
+ * a live status: a strand stopped or failed mid-rebuild must not gain a timer chain. Nor
594
+ * one being quiesced: it reads live until the quiesce ends, e.g. a force-hibernate queued
595
+ * behind the rebuild of the wake settling now, and the countdown would later idle the
596
+ * quiesced strand and start a check-in chain the caller meant to keep off.
597
+ */
598
+ private rearmIdleIfLive(instance: StrandInstance): void {
599
+ const live = (instance.status === 'active' || instance.status === 'syncing')
600
+ && !this.callbacks.isQuiescing(instance.strandId);
601
+ if (live && this.getTimeouts(instance.latencyHint).idleTimeout !== Infinity) {
602
+ this.scheduleIdleTransition(instance);
603
+ }
604
+ }
605
+
606
+ private clearTimer(strandId: string): void {
607
+ const timer = this.timers.get(strandId);
608
+ if (timer) {
609
+ clearTimeout(timer);
610
+ this.timers.delete(strandId);
611
+ }
612
+ }
613
+
614
+ private clearCheckInTimer(strandId: string): void {
615
+ const pending = this.checkInTimers.get(strandId);
616
+ if (pending) {
617
+ clearTimeout(pending.timer);
618
+ this.checkInTimers.delete(strandId);
619
+ // `getStrand` must not advertise a check-in that is no longer scheduled.
620
+ pending.instance.nextCheckIn = undefined;
621
+ }
622
+ }
623
+
624
+ private clearTimers(strandId: string): void {
625
+ this.clearTimer(strandId);
626
+ this.clearCheckInTimer(strandId);
627
+ }
628
+
629
+ /**
630
+ * Get the current status of hibernation tracking
631
+ */
632
+ getStatus(): { enabled: boolean; trackedStrands: number } {
633
+ return {
634
+ enabled: this.config.enabled && this.running,
635
+ trackedStrands: this.timers.size + this.checkInTimers.size
636
+ };
637
+ }
638
+ }