@steve31415/baselib 3.31.0 → 3.33.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.
@@ -65,10 +65,17 @@ export declare class BuildUpdateMonitor {
65
65
  /** Deployment push is intentionally only a prompt to ask canonical HTTP;
66
66
  * it carries no traffic authority of its own and runs while hidden too. */
67
67
  observeDeploymentHint(): void;
68
+ /** Asks canonical HTTP for the traffic build. Nothing to ask once the page
69
+ * is reloading: the navigation aborts the request, so a check then can only
70
+ * log a false failure (digest, 2026-10-06: a page hidden and shown within
71
+ * 0.4 s ran its deferred reload on the hide and this check on the show). */
68
72
  checkNow(): Promise<void>;
69
73
  private observeTrafficBuild;
70
74
  private markAvailable;
71
75
  private maybeReload;
76
+ /** A method, not an inline test: TypeScript would keep the narrowing from
77
+ * checkNow's entry across its await. */
78
+ private isReloading;
72
79
  private justReturned;
73
80
  private setState;
74
81
  /** Polls only while visible: an interval in a hidden page would wake it
@@ -91,8 +91,12 @@ export class BuildUpdateMonitor {
91
91
  observeDeploymentHint() {
92
92
  void this.checkNow();
93
93
  }
94
+ /** Asks canonical HTTP for the traffic build. Nothing to ask once the page
95
+ * is reloading: the navigation aborts the request, so a check then can only
96
+ * log a false failure (digest, 2026-10-06: a page hidden and shown within
97
+ * 0.4 s ran its deferred reload on the hide and this check on the show). */
94
98
  async checkNow() {
95
- if (!navigator.onLine)
99
+ if (!navigator.onLine || this.isReloading())
96
100
  return;
97
101
  const lifecycle = this.lifecycle;
98
102
  const requestNumber = ++this.requestNumber;
@@ -130,7 +134,8 @@ export class BuildUpdateMonitor {
130
134
  catch (error) {
131
135
  if (lifecycle !== this.lifecycle ||
132
136
  requestNumber < this.appliedRequestNumber ||
133
- !navigator.onLine)
137
+ !navigator.onLine ||
138
+ this.isReloading())
134
139
  return;
135
140
  if (!this.checkFailure) {
136
141
  this.checkFailure = true;
@@ -185,6 +190,11 @@ export class BuildUpdateMonitor {
185
190
  else
186
191
  location.reload();
187
192
  }
193
+ /** A method, not an inline test: TypeScript would keep the narrowing from
194
+ * checkNow's entry across its await. */
195
+ isReloading() {
196
+ return this.state.kind === 'reloading';
197
+ }
188
198
  justReturned() {
189
199
  return (document.visibilityState === 'visible' &&
190
200
  this.returnedAt !== null &&
@@ -32,6 +32,9 @@ export interface CatchupResult<S extends EventBody> {
32
32
  }
33
33
  export interface SyncBrowserRuntime {
34
34
  now(): number;
35
+ /** A monotonic clock (`performance.now()`), used only to tell a page that
36
+ * was not running from one that was. Defaults to `performance.now()`. */
37
+ monotonicNow?(): number;
35
38
  randomGuid(): string;
36
39
  createSocket(url: string): WebSocket;
37
40
  sendBeacon(url: string, body: Blob): boolean;
@@ -154,6 +157,8 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
154
157
  private loopEscalated;
155
158
  private reconciliationTail;
156
159
  private initialization;
160
+ /** Non-null while the first bootstrap is loading, retries included. */
161
+ private firstLoadRetry;
157
162
  private disposed;
158
163
  private hiddenSince;
159
164
  private hiddenSocketFailed;
@@ -171,10 +176,21 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
171
176
  private lastContactAt;
172
177
  private spellCounters;
173
178
  private returnWindow;
179
+ private clockOffset;
180
+ private stallCoveredUntil;
181
+ private visibleSinceMono;
174
182
  private readonly counters;
175
183
  constructor(options: SyncClientOptions<B, C, S, Summary>);
176
184
  init(): Promise<void>;
177
185
  private initialize;
186
+ /** The first bootstrap. One that gets no HTTP answer (fetch's TypeError: no
187
+ * network, or one that reaches nothing — a tab Android Chrome restored from
188
+ * its cache) is retried until it loads, while init() stays pending and the
189
+ * state says `disconnected`; any other failure rejects, as it always did
190
+ * (docs/SYNC-INIT-RECOVERY-PLAN.md). Rejects with a LifecycleAbort once
191
+ * halted, disposed, or abandoned by a failed outbox. */
192
+ private loadFirstBootstrap;
193
+ private createFirstLoadRetry;
178
194
  dispose(): void;
179
195
  submit(event: ClientEvent<C>, summary: Summary): Promise<void>;
180
196
  /** Current-tab entries only; recovered work is retried silently. */
@@ -192,6 +208,17 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
192
208
  state(): SyncClientState;
193
209
  subscribe(listener: (state: SyncClientState) => void): () => void;
194
210
  private installRuntimeListeners;
211
+ private monoNow;
212
+ private rebaselineClocks;
213
+ /** Runs first in every client timer. A device asleep stops the monotonic
214
+ * clock and the timers with it, so the wall clock's lead over it grows by
215
+ * the sleep; a frozen page leaves both clocks running and its timers run
216
+ * late by the monotonic one. Overlapping late timers count once. Only time
217
+ * the page counted as visible is counted (hidden pages are throttled by
218
+ * design and their time is `hiddenMs`). */
219
+ private observeStall;
220
+ private setTimer;
221
+ private setRepeating;
195
222
  private isHidden;
196
223
  private now;
197
224
  /** Counters with the running hidden spell's time folded in. */
@@ -1,3 +1,4 @@
1
+ import { FirstLoadRetry } from './first-load-retry.js';
1
2
  const RECONNECT_DELAYS_SECONDS = [1, 2, 4, 8, 16, 30];
2
3
  const PING_INTERVAL_MS = 30_000;
3
4
  const CONNECT_WATCHDOG_MS = 10_000;
@@ -35,6 +36,9 @@ const RETURN_QUICK_RETRY_MS = 250;
35
36
  * resolves (notes, 2026-10-01: four minutes). Fast failures (offline) are
36
37
  * not logged. */
37
38
  const SLOW_HANDSHAKE_MS = 3_000;
39
+ /** A timer that runs at least this late, or a wall-clock jump of at least
40
+ * this much past the monotonic clock, counts as time the page was stalled. */
41
+ const STALL_MIN_MS = 1_000;
38
42
  const SOCKET_CONNECTING = 0;
39
43
  const SOCKET_OPEN = 1;
40
44
  class LifecycleAbort {
@@ -88,6 +92,8 @@ export class SyncClient {
88
92
  loopEscalated = false;
89
93
  reconciliationTail = Promise.resolve();
90
94
  initialization = null;
95
+ /** Non-null while the first bootstrap is loading, retries included. */
96
+ firstLoadRetry = null;
91
97
  disposed = false;
92
98
  // Hidden-page state (docs/SYNC-BACKGROUND-PLAN.md).
93
99
  hiddenSince = null;
@@ -106,8 +112,13 @@ export class SyncClient {
106
112
  lastContactAt = null;
107
113
  spellCounters = null;
108
114
  returnWindow = null;
115
+ // Stall accounting (Counters.stalledMs).
116
+ clockOffset = null;
117
+ stallCoveredUntil = 0;
118
+ visibleSinceMono = 0;
109
119
  counters = {
110
120
  hiddenMs: 0,
121
+ stalledMs: 0,
111
122
  freezes: 0,
112
123
  resumes: 0,
113
124
  watchdogKills: 0,
@@ -150,16 +161,7 @@ export class SyncClient {
150
161
  throw error;
151
162
  });
152
163
  this.options.markPerformance?.('bootstrap-start');
153
- const bootstrapPromise = this.abortable((signal) => this.options.fetchBootstrap(signal))
154
- .then((bootstrap) => {
155
- this.options.markPerformance?.('bootstrap-response');
156
- return bootstrap;
157
- })
158
- .catch((error) => {
159
- if (!isLifecycleAbort(error))
160
- this.options.logger.error('bootstrap failed', { error });
161
- throw error;
162
- });
164
+ const bootstrapPromise = this.loadFirstBootstrap();
163
165
  let recovered;
164
166
  let bootstrap;
165
167
  try {
@@ -169,6 +171,10 @@ export class SyncClient {
169
171
  }
170
172
  catch (error) {
171
173
  this.abortFetches();
174
+ this.firstLoadRetry?.stop();
175
+ // A halt or dispose is a deliberate stop, not a failed load.
176
+ if (this.halted || this.disposed)
177
+ return;
172
178
  throw isLifecycleAbort(error) ? error.reason : error;
173
179
  }
174
180
  if (this.stopBeforeStoreMutation())
@@ -194,11 +200,77 @@ export class SyncClient {
194
200
  this.installRuntimeListeners();
195
201
  this.connect();
196
202
  }
203
+ /** The first bootstrap. One that gets no HTTP answer (fetch's TypeError: no
204
+ * network, or one that reaches nothing — a tab Android Chrome restored from
205
+ * its cache) is retried until it loads, while init() stays pending and the
206
+ * state says `disconnected`; any other failure rejects, as it always did
207
+ * (docs/SYNC-INIT-RECOVERY-PLAN.md). Rejects with a LifecycleAbort once
208
+ * halted, disposed, or abandoned by a failed outbox. */
209
+ async loadFirstBootstrap() {
210
+ const retry = this.createFirstLoadRetry();
211
+ this.firstLoadRetry = retry;
212
+ try {
213
+ for (;;) {
214
+ const controller = new AbortController();
215
+ this.abortControllers.add(controller);
216
+ retry.attemptStarted(() => controller.abort());
217
+ try {
218
+ const bootstrap = await this.options.fetchBootstrap(controller.signal);
219
+ retry.attemptEnded('loaded');
220
+ this.options.markPerformance?.('bootstrap-response');
221
+ retry.recovered();
222
+ return bootstrap;
223
+ }
224
+ catch (error) {
225
+ retry.attemptEnded('failed');
226
+ if (this.halted || this.disposed || retry.stopped)
227
+ throw new LifecycleAbort(error);
228
+ // An abort here is the retry replacing a stale attempt: no answer.
229
+ if (!controller.signal.aborted && !(error instanceof TypeError)) {
230
+ this.options.logger.error('bootstrap failed', { reachedServer: true, failures: retry.failures, error });
231
+ this.publish({ kind: 'error', pending: this.pendingCount(), message: 'Failed to load' });
232
+ throw error;
233
+ }
234
+ retry.failed(error);
235
+ if (retry.failures === 1)
236
+ this.publish({ kind: 'disconnected', pending: this.pendingCount() });
237
+ }
238
+ finally {
239
+ this.abortControllers.delete(controller);
240
+ }
241
+ const proceed = await retry.next();
242
+ if (!proceed || this.stopBeforeStoreMutation())
243
+ throw new LifecycleAbort('first load stopped');
244
+ }
245
+ }
246
+ finally {
247
+ retry.stop();
248
+ if (this.firstLoadRetry === retry)
249
+ this.firstLoadRetry = null;
250
+ }
251
+ }
252
+ createFirstLoadRetry() {
253
+ const runtime = this.options.runtime;
254
+ return new FirstLoadRetry({
255
+ now: () => this.now(),
256
+ visible: () => runtime.visibility() !== 'hidden',
257
+ setTimer: (callback, delayMs) => this.setTimer(callback, delayMs),
258
+ clearTimer: (timer) => runtime.clearTimeout(timer),
259
+ onOnline: (listener) => runtime.onOnline(listener),
260
+ onVisibilityChange: (listener) => runtime.onVisibilityChange(listener),
261
+ logger: this.options.logger,
262
+ visibleDelaysMs: RECONNECT_DELAYS_SECONDS.map((seconds) => seconds * 1000),
263
+ hiddenDelayMs: this.options.backgroundCatchupMs ?? DEFAULT_BACKGROUND_CATCHUP_MS,
264
+ escalateAfterMs: OUTAGE_ESCALATE_MS,
265
+ staleAttemptMs: CONNECT_WATCHDOG_MS,
266
+ });
267
+ }
197
268
  dispose() {
198
269
  if (this.disposed)
199
270
  return;
200
271
  this.disposed = true;
201
272
  this.abortFetches();
273
+ this.firstLoadRetry?.stop();
202
274
  for (const remove of this.removeListeners.splice(0))
203
275
  remove();
204
276
  this.clearAllTimers();
@@ -277,6 +349,7 @@ export class SyncClient {
277
349
  }
278
350
  installRuntimeListeners() {
279
351
  const runtime = this.options.runtime;
352
+ this.rebaselineClocks();
280
353
  // Loaded hidden (a background tab): the spell starts now; the first
281
354
  // connect still runs, and its failure goes dormant rather than retrying.
282
355
  if (runtime.visibility() === 'hidden') {
@@ -296,6 +369,48 @@ export class SyncClient {
296
369
  if (onResume)
297
370
  this.removeListeners.push(onResume);
298
371
  }
372
+ monoNow() {
373
+ return this.options.runtime.monotonicNow?.() ?? performance.now();
374
+ }
375
+ rebaselineClocks() {
376
+ this.clockOffset = this.now() - this.monoNow();
377
+ }
378
+ /** Runs first in every client timer. A device asleep stops the monotonic
379
+ * clock and the timers with it, so the wall clock's lead over it grows by
380
+ * the sleep; a frozen page leaves both clocks running and its timers run
381
+ * late by the monotonic one. Overlapping late timers count once. Only time
382
+ * the page counted as visible is counted (hidden pages are throttled by
383
+ * design and their time is `hiddenMs`). */
384
+ observeStall(dueMono) {
385
+ const mono = this.monoNow();
386
+ const offset = this.now() - mono;
387
+ const visible = !this.isHidden();
388
+ if (visible && this.clockOffset !== null && offset - this.clockOffset >= STALL_MIN_MS) {
389
+ this.counters.stalledMs += offset - this.clockOffset;
390
+ }
391
+ this.clockOffset = offset;
392
+ if (visible && mono - dueMono >= STALL_MIN_MS) {
393
+ const from = Math.max(dueMono, this.stallCoveredUntil, this.visibleSinceMono);
394
+ if (mono > from)
395
+ this.counters.stalledMs += mono - from;
396
+ this.stallCoveredUntil = mono;
397
+ }
398
+ }
399
+ setTimer(callback, delayMs) {
400
+ const dueMono = this.monoNow() + delayMs;
401
+ return this.options.runtime.setTimeout(() => {
402
+ this.observeStall(dueMono);
403
+ callback();
404
+ }, delayMs);
405
+ }
406
+ setRepeating(callback, periodMs) {
407
+ let dueMono = this.monoNow() + periodMs;
408
+ return this.options.runtime.setInterval(() => {
409
+ this.observeStall(dueMono);
410
+ dueMono = this.monoNow() + periodMs;
411
+ callback();
412
+ }, periodMs);
413
+ }
299
414
  isHidden() {
300
415
  return this.hiddenSince !== null;
301
416
  }
@@ -310,6 +425,7 @@ export class SyncClient {
310
425
  static delta(since, now) {
311
426
  return {
312
427
  hiddenMs: Math.max(0, now.hiddenMs - since.hiddenMs),
428
+ stalledMs: now.stalledMs - since.stalledMs,
313
429
  freezes: now.freezes - since.freezes,
314
430
  resumes: now.resumes - since.resumes,
315
431
  watchdogKills: now.watchdogKills - since.watchdogKills,
@@ -323,6 +439,8 @@ export class SyncClient {
323
439
  if (this.halted || this.disposed || this.hiddenSince !== null)
324
440
  return;
325
441
  this.finishReturnWindow();
442
+ // A device that slept and then hid the page: the sleep counts as hidden.
443
+ this.rebaselineClocks();
326
444
  this.hiddenSince = this.now();
327
445
  this.spellCounters = this.snapshot();
328
446
  this.hiddenSocketFailed = false;
@@ -339,6 +457,8 @@ export class SyncClient {
339
457
  return;
340
458
  const hiddenSince = this.hiddenSince;
341
459
  if (hiddenSince !== null) {
460
+ this.rebaselineClocks();
461
+ this.visibleSinceMono = this.monoNow();
342
462
  const hiddenMs = Math.max(0, this.now() - hiddenSince);
343
463
  const spell = this.spellCounters ?? this.snapshot();
344
464
  this.counters.hiddenMs += hiddenMs;
@@ -382,7 +502,7 @@ export class SyncClient {
382
502
  // replaces it.
383
503
  if (this.connectReplyTimer !== null)
384
504
  this.options.runtime.clearTimeout(this.connectReplyTimer);
385
- this.connectReplyTimer = this.options.runtime.setTimeout(() => {
505
+ this.connectReplyTimer = this.setTimer(() => {
386
506
  this.connectReplyTimer = null;
387
507
  if (this.socket === socket && !this.isHidden())
388
508
  this.replaceSocket();
@@ -452,7 +572,7 @@ export class SyncClient {
452
572
  if (this.socketCreatedHidden)
453
573
  this.counters.backgroundSocketAttempts++;
454
574
  let killedByWatchdog = false;
455
- this.watchdogTimer = this.options.runtime.setTimeout(() => {
575
+ this.watchdogTimer = this.setTimer(() => {
456
576
  if (socket.readyState !== SOCKET_OPEN) {
457
577
  this.counters.watchdogKills++;
458
578
  killedByWatchdog = true;
@@ -479,8 +599,8 @@ export class SyncClient {
479
599
  }
480
600
  this.cancelBackgroundTick();
481
601
  this.sendConnect();
482
- this.pingTimer = this.options.runtime.setInterval(() => this.sendRaw({ type: 'ping' }), PING_INTERVAL_MS);
483
- this.rolloverTimer = this.options.runtime.setTimeout(() => {
602
+ this.pingTimer = this.setRepeating(() => this.sendRaw({ type: 'ping' }), PING_INTERVAL_MS);
603
+ this.rolloverTimer = this.setTimer(() => {
484
604
  this.options.logger.info('ws rollover', {});
485
605
  this.plannedClose = socket;
486
606
  socket.close();
@@ -556,7 +676,7 @@ export class SyncClient {
556
676
  return;
557
677
  const interval = this.options.backgroundCatchupMs ?? DEFAULT_BACKGROUND_CATCHUP_MS;
558
678
  const due = this.lastTickAt === null ? 0 : Math.max(0, this.lastTickAt + interval - this.now());
559
- this.backgroundTickTimer = this.options.runtime.setTimeout(() => {
679
+ this.backgroundTickTimer = this.setTimer(() => {
560
680
  this.backgroundTickTimer = null;
561
681
  void this.backgroundTick();
562
682
  }, Math.min(due, interval));
@@ -633,7 +753,7 @@ export class SyncClient {
633
753
  const slot = { kind, controller };
634
754
  this.catchupSlot = slot;
635
755
  this.abortControllers.add(controller);
636
- const timeout = this.options.runtime.setTimeout(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
756
+ const timeout = this.setTimer(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
637
757
  let ok = false;
638
758
  try {
639
759
  const result = await this.options.fetchCatchup(this.options.store.currentSeq(), controller.signal);
@@ -678,7 +798,7 @@ export class SyncClient {
678
798
  async withTimeout(operation) {
679
799
  const controller = new AbortController();
680
800
  this.abortControllers.add(controller);
681
- const timeout = this.options.runtime.setTimeout(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
801
+ const timeout = this.setTimer(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
682
802
  try {
683
803
  return await operation(controller.signal);
684
804
  }
@@ -704,7 +824,7 @@ export class SyncClient {
704
824
  failures: 0,
705
825
  firstFailure: null,
706
826
  quickRetryUsed: false,
707
- timer: this.options.runtime.setTimeout(() => this.finishReturnWindow(), RETURN_REPORT_TIMEOUT_MS),
827
+ timer: this.setTimer(() => this.finishReturnWindow(), RETURN_REPORT_TIMEOUT_MS),
708
828
  };
709
829
  }
710
830
  maybeFinishReturnWindow() {
@@ -785,13 +905,13 @@ export class SyncClient {
785
905
  }
786
906
  let remaining = delay;
787
907
  this.publish({ kind: 'reconnecting', pending: this.pendingCount(), inSeconds: remaining });
788
- this.countdownTimer = this.options.runtime.setInterval(() => {
908
+ this.countdownTimer = this.setRepeating(() => {
789
909
  remaining--;
790
910
  if (remaining > 0) {
791
911
  this.publish({ kind: 'reconnecting', pending: this.pendingCount(), inSeconds: remaining });
792
912
  }
793
913
  }, 1000);
794
- this.reconnectTimer = this.options.runtime.setTimeout(() => {
914
+ this.reconnectTimer = this.setTimer(() => {
795
915
  if (this.countdownTimer !== null)
796
916
  this.options.runtime.clearInterval(this.countdownTimer);
797
917
  this.countdownTimer = null;
@@ -858,7 +978,7 @@ export class SyncClient {
858
978
  if (this.escalateTimer !== null)
859
979
  this.options.runtime.clearTimeout(this.escalateTimer);
860
980
  this.escalateArmedAt = this.options.runtime.now();
861
- this.escalateTimer = this.options.runtime.setTimeout(() => {
981
+ this.escalateTimer = this.setTimer(() => {
862
982
  this.escalateTimer = null;
863
983
  const late = this.options.runtime.now() - this.escalateArmedAt - OUTAGE_ESCALATE_MS;
864
984
  if (this.options.runtime.visibility() === 'hidden' || late > 5000)
@@ -1153,7 +1273,7 @@ export class SyncClient {
1153
1273
  }
1154
1274
  armFailureTimer(eventGuid) {
1155
1275
  this.clearFailureTimer(eventGuid);
1156
- this.failureTimers.set(eventGuid, this.options.runtime.setTimeout(() => this.failUnconfirmed(eventGuid), FAILURE_TIMEOUT_MS));
1276
+ this.failureTimers.set(eventGuid, this.setTimer(() => this.failUnconfirmed(eventGuid), FAILURE_TIMEOUT_MS));
1157
1277
  }
1158
1278
  /** On return: a verdict needs 60 s of visible time (review B2). */
1159
1279
  rearmFailureTimers() {
@@ -1195,6 +1315,7 @@ export class SyncClient {
1195
1315
  wasClean: info?.wasClean,
1196
1316
  visibleAtDrop: info?.visibleAtDrop,
1197
1317
  hiddenMs: delta?.hiddenMs,
1318
+ stalledMs: delta?.stalledMs,
1198
1319
  watchdogKills: delta?.watchdogKills,
1199
1320
  backgroundCatchups: delta?.backgroundCatchups,
1200
1321
  httpPostedEvents: delta?.httpPostedEvents,
@@ -1332,7 +1453,7 @@ export class SyncClient {
1332
1453
  return;
1333
1454
  // A hidden tab's timer throttling may stretch this well past the nominal
1334
1455
  // delay; that only postpones recovery, it never breaks it.
1335
- this.resyncBackoffTimer = this.options.runtime.setTimeout(() => {
1456
+ this.resyncBackoffTimer = this.setTimer(() => {
1336
1457
  this.resyncBackoffTimer = null;
1337
1458
  void this.resyncFromServer('retry after resync backoff');
1338
1459
  }, RESYNC_RETRY_MS);
@@ -1431,6 +1552,7 @@ export class SyncClient {
1431
1552
  return;
1432
1553
  this.halted = true;
1433
1554
  this.abortFetches();
1555
+ this.firstLoadRetry?.stop();
1434
1556
  this.clearAllTimers();
1435
1557
  const socket = this.socket;
1436
1558
  this.socket = null;
@@ -0,0 +1,58 @@
1
+ import type { Logger } from '../log-browser.js';
2
+ type TimeoutHandle = ReturnType<typeof globalThis.setTimeout>;
3
+ export interface FirstLoadRetryOptions {
4
+ now(): number;
5
+ visible(): boolean;
6
+ setTimer(callback: () => void, delayMs: number): TimeoutHandle;
7
+ clearTimer(timer: TimeoutHandle): void;
8
+ onOnline(listener: () => void): () => void;
9
+ onVisibilityChange(listener: () => void): () => void;
10
+ logger: Pick<Logger, 'info' | 'warn' | 'error'>;
11
+ /** The wait after each failure while visible; the last one repeats. */
12
+ visibleDelaysMs: readonly number[];
13
+ hiddenDelayMs: number;
14
+ /** Not loaded after this much visible, on-time loading: one ERROR. */
15
+ escalateAfterMs: number;
16
+ /** A trigger replaces an attempt that has run this long; an attempt this
17
+ * long is logged (INFO `bootstrap slow`). */
18
+ staleAttemptMs: number;
19
+ }
20
+ export declare class FirstLoadRetry {
21
+ private readonly options;
22
+ private failureCount;
23
+ private readonly startedAt;
24
+ /** Ladder position of the next visible wait; a return restarts it. */
25
+ private step;
26
+ private escalated;
27
+ private escalateTimer;
28
+ private waitTimer;
29
+ private wake;
30
+ private attempt;
31
+ /** A trigger arrived during the running attempt: if it fails, no wait. */
32
+ private triggered;
33
+ private stoppedFlag;
34
+ private readonly removeListeners;
35
+ /** Created as the first attempt starts. */
36
+ constructor(options: FirstLoadRetryOptions);
37
+ get failures(): number;
38
+ get stopped(): boolean;
39
+ /** `abort` cancels this attempt (its fetch's AbortSignal). */
40
+ attemptStarted(abort: () => void): void;
41
+ attemptEnded(outcome: 'loaded' | 'failed'): void;
42
+ /** An attempt failed without an HTTP answer (or was replaced as stale).
43
+ * One WARN per streak. */
44
+ failed(error: unknown): void;
45
+ /** Resolves true when the next attempt is due (timer, `online`, a return
46
+ * to visible, or a trigger during the attempt that just failed), false
47
+ * once stopped. */
48
+ next(): Promise<boolean>;
49
+ recovered(): void;
50
+ /** Removes listeners and timers and ends a running wait (`next` → false). */
51
+ stop(): void;
52
+ private trigger;
53
+ private release;
54
+ /** ERROR only once the load has lasted `escalateAfterMs` in view, on time;
55
+ * re-armed on each return. */
56
+ private armEscalation;
57
+ }
58
+ export {};
@@ -0,0 +1,167 @@
1
+ // First-load recovery (docs/SYNC-INIT-RECOVERY-PLAN.md): while the first
2
+ // bootstrap has not loaded, this decides when the next attempt is due and
3
+ // owns the streak's logs. SyncClient makes the attempts, one at a time; a
4
+ // trigger (`online`, a return to visible) never starts a second attempt next
5
+ // to a running one: it ends the wait between attempts, or marks the running
6
+ // attempt so that its failure retries at once, or, when that attempt has
7
+ // already run `staleAttemptMs`, aborts it so a fresh one starts.
8
+ /** A timer this late ran after the device slept or the page was frozen. */
9
+ const LATE_TIMER_MS = 1_000;
10
+ /** An escalation timer this late: that time was not spent loading in front
11
+ * of the user (as the socket's `sync outage ongoing`). */
12
+ const ESCALATE_LATE_TOLERANCE_MS = 5_000;
13
+ export class FirstLoadRetry {
14
+ options;
15
+ failureCount = 0;
16
+ startedAt;
17
+ /** Ladder position of the next visible wait; a return restarts it. */
18
+ step = 0;
19
+ escalated = false;
20
+ escalateTimer = null;
21
+ waitTimer = null;
22
+ wake = null;
23
+ attempt = null;
24
+ /** A trigger arrived during the running attempt: if it fails, no wait. */
25
+ triggered = false;
26
+ stoppedFlag = false;
27
+ removeListeners;
28
+ /** Created as the first attempt starts. */
29
+ constructor(options) {
30
+ this.options = options;
31
+ this.startedAt = options.now();
32
+ this.removeListeners = [
33
+ options.onOnline(() => this.trigger()),
34
+ options.onVisibilityChange(() => {
35
+ if (!this.options.visible())
36
+ return;
37
+ this.step = 0;
38
+ this.armEscalation();
39
+ this.trigger();
40
+ }),
41
+ ];
42
+ this.armEscalation();
43
+ }
44
+ get failures() {
45
+ return this.failureCount;
46
+ }
47
+ get stopped() {
48
+ return this.stoppedFlag;
49
+ }
50
+ /** `abort` cancels this attempt (its fetch's AbortSignal). */
51
+ attemptStarted(abort) {
52
+ this.attempt = { startedAt: this.options.now(), abort };
53
+ this.triggered = false;
54
+ }
55
+ attemptEnded(outcome) {
56
+ const attempt = this.attempt;
57
+ this.attempt = null;
58
+ if (attempt === null || this.stoppedFlag)
59
+ return;
60
+ const attemptMs = this.options.now() - attempt.startedAt;
61
+ if (attemptMs >= this.options.staleAttemptMs) {
62
+ this.options.logger.info('bootstrap slow', { attemptMs, outcome, failures: this.failureCount });
63
+ }
64
+ }
65
+ /** An attempt failed without an HTTP answer (or was replaced as stale).
66
+ * One WARN per streak. */
67
+ failed(error) {
68
+ this.failureCount++;
69
+ if (this.failureCount > 1)
70
+ return;
71
+ this.options.logger.warn('bootstrap failed', { reachedServer: false, hidden: !this.options.visible(), error });
72
+ }
73
+ /** Resolves true when the next attempt is due (timer, `online`, a return
74
+ * to visible, or a trigger during the attempt that just failed), false
75
+ * once stopped. */
76
+ next() {
77
+ if (this.stoppedFlag)
78
+ return Promise.resolve(false);
79
+ if (this.triggered) {
80
+ this.triggered = false;
81
+ return Promise.resolve(true);
82
+ }
83
+ const visible = this.options.visible();
84
+ const delays = this.options.visibleDelaysMs;
85
+ const delayMs = visible ? delays[Math.min(this.step, delays.length - 1)] : this.options.hiddenDelayMs;
86
+ if (visible)
87
+ this.step++;
88
+ const dueAt = this.options.now() + delayMs;
89
+ return new Promise((resolve) => {
90
+ this.wake = resolve;
91
+ this.waitTimer = this.options.setTimer(() => {
92
+ this.waitTimer = null;
93
+ // Late: the device slept or the page was frozen — a phone whose
94
+ // screen went off with the page in front sends no visibilitychange.
95
+ // Waking is a return: restart the ladder.
96
+ if (this.options.now() - dueAt >= LATE_TIMER_MS)
97
+ this.step = 0;
98
+ this.release(true);
99
+ }, delayMs);
100
+ });
101
+ }
102
+ recovered() {
103
+ if (this.failureCount > 0) {
104
+ this.options.logger.info('bootstrap recovered', {
105
+ failures: this.failureCount,
106
+ loadingForMs: Math.max(0, this.options.now() - this.startedAt),
107
+ escalated: this.escalated,
108
+ });
109
+ }
110
+ this.stop();
111
+ }
112
+ /** Removes listeners and timers and ends a running wait (`next` → false). */
113
+ stop() {
114
+ if (this.stoppedFlag)
115
+ return;
116
+ this.stoppedFlag = true;
117
+ for (const remove of this.removeListeners.splice(0))
118
+ remove();
119
+ if (this.escalateTimer !== null)
120
+ this.options.clearTimer(this.escalateTimer);
121
+ this.escalateTimer = null;
122
+ this.release(false);
123
+ }
124
+ trigger() {
125
+ if (this.wake !== null) {
126
+ this.release(true);
127
+ return;
128
+ }
129
+ const attempt = this.attempt;
130
+ if (attempt === null)
131
+ return;
132
+ this.triggered = true;
133
+ // Probably hung: on a dead path a request can wait out the OS connect
134
+ // timeout (minutes), and this trigger says the network or page changed.
135
+ if (this.options.now() - attempt.startedAt >= this.options.staleAttemptMs)
136
+ attempt.abort();
137
+ }
138
+ release(proceed) {
139
+ if (this.waitTimer !== null)
140
+ this.options.clearTimer(this.waitTimer);
141
+ this.waitTimer = null;
142
+ const wake = this.wake;
143
+ this.wake = null;
144
+ wake?.(proceed);
145
+ }
146
+ /** ERROR only once the load has lasted `escalateAfterMs` in view, on time;
147
+ * re-armed on each return. */
148
+ armEscalation() {
149
+ if (this.stoppedFlag || this.escalated || !this.options.visible())
150
+ return;
151
+ if (this.escalateTimer !== null)
152
+ this.options.clearTimer(this.escalateTimer);
153
+ const armedAt = this.options.now();
154
+ this.escalateTimer = this.options.setTimer(() => {
155
+ this.escalateTimer = null;
156
+ const lateMs = this.options.now() - armedAt - this.options.escalateAfterMs;
157
+ if (!this.options.visible() || lateMs > ESCALATE_LATE_TOLERANCE_MS)
158
+ return;
159
+ this.escalated = true;
160
+ this.options.logger.error('bootstrap still failing', {
161
+ failures: this.failureCount,
162
+ loadingForMs: Math.max(0, this.options.now() - this.startedAt),
163
+ attemptMs: this.attempt === null ? null : this.options.now() - this.attempt.startedAt,
164
+ });
165
+ }, this.options.escalateAfterMs);
166
+ }
167
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.31.0",
3
+ "version": "3.33.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",