@steve31415/baselib 3.19.1 → 3.20.1

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.
@@ -50,6 +50,11 @@ export declare class BuildUpdateMonitor {
50
50
  private markAvailable;
51
51
  private maybeReload;
52
52
  private setState;
53
+ /** Polls only while visible: an interval in a hidden page would wake it
54
+ * every few minutes to do nothing. Hidden pages learn of deploys from the
55
+ * sync client's pushes and background catch-ups. */
56
+ private startPolling;
57
+ private stopPolling;
53
58
  private onVisibility;
54
59
  private onOnline;
55
60
  }
@@ -32,10 +32,8 @@ export class BuildUpdateMonitor {
32
32
  this.started = true;
33
33
  this.lifecycle += 1;
34
34
  this.stopSafety = this.deps.safety.subscribe(() => this.maybeReload());
35
- this.interval = setInterval(() => {
36
- if (document.visibilityState === 'visible')
37
- void this.checkNow();
38
- }, this.deps.pollMs ?? DEFAULT_POLL_MS);
35
+ if (document.visibilityState === 'visible')
36
+ this.startPolling();
39
37
  document.addEventListener('visibilitychange', this.onVisibility);
40
38
  window.addEventListener('online', this.onOnline);
41
39
  void this.checkNow();
@@ -45,9 +43,7 @@ export class BuildUpdateMonitor {
45
43
  return;
46
44
  this.started = false;
47
45
  this.lifecycle += 1;
48
- if (this.interval)
49
- clearInterval(this.interval);
50
- this.interval = null;
46
+ this.stopPolling();
51
47
  this.stopSafety?.();
52
48
  this.stopSafety = null;
53
49
  document.removeEventListener('visibilitychange', this.onVisibility);
@@ -151,9 +147,30 @@ export class BuildUpdateMonitor {
151
147
  for (const listener of this.listeners)
152
148
  listener();
153
149
  }
150
+ /** Polls only while visible: an interval in a hidden page would wake it
151
+ * every few minutes to do nothing. Hidden pages learn of deploys from the
152
+ * sync client's pushes and background catch-ups. */
153
+ startPolling() {
154
+ if (this.interval)
155
+ return;
156
+ this.interval = setInterval(() => {
157
+ if (document.visibilityState === 'visible')
158
+ void this.checkNow();
159
+ }, this.deps.pollMs ?? DEFAULT_POLL_MS);
160
+ }
161
+ stopPolling() {
162
+ if (this.interval)
163
+ clearInterval(this.interval);
164
+ this.interval = null;
165
+ }
154
166
  onVisibility = () => {
155
- if (document.visibilityState === 'visible')
167
+ if (document.visibilityState === 'visible') {
168
+ this.startPolling();
156
169
  void this.checkNow();
170
+ }
171
+ else {
172
+ this.stopPolling();
173
+ }
157
174
  };
158
175
  onOnline = () => {
159
176
  void this.checkNow();
@@ -1,5 +1,5 @@
1
1
  import type { Logger as BrowserLogger } from '../log-browser.js';
2
- import type { ClientEvent, EventBody, SyncEvent } from '../sync/index.js';
2
+ import type { ClientEvent, EventBody, HttpEventOutcome, SyncEvent } from '../sync/index.js';
3
3
  import type { IndexedDbOutbox } from './outbox.js';
4
4
  export interface PendingEntry<C extends EventBody, Summary = unknown> {
5
5
  event: ClientEvent<C>;
@@ -39,6 +39,10 @@ export interface SyncBrowserRuntime {
39
39
  onVisibilityChange(listener: () => void): () => void;
40
40
  onOnline(listener: () => void): () => void;
41
41
  onPageHide(listener: () => void): () => void;
42
+ /** Page Lifecycle `freeze` / `resume` on `document`; optional, counted
43
+ * for instrumentation only. */
44
+ onFreeze?(listener: () => void): () => void;
45
+ onResume?(listener: () => void): () => void;
42
46
  setTimeout: typeof globalThis.setTimeout;
43
47
  clearTimeout: typeof globalThis.clearTimeout;
44
48
  setInterval: typeof globalThis.setInterval;
@@ -95,6 +99,18 @@ export interface SyncClientOptions<B, C extends EventBody, S extends EventBody,
95
99
  onBuildChanged?(): void;
96
100
  markPerformance?(name: 'outbox-start' | 'outbox-ready' | 'bootstrap-start' | 'bootstrap-response' | 'store-ready'): void;
97
101
  reconnectBeforeMs?: number;
102
+ /** While hidden and disconnected, one HTTP catch-up per this interval
103
+ * (default 5 min). */
104
+ backgroundCatchupMs?: number;
105
+ /** POSTs events to the app's batch endpoint (the beacon URL) and returns
106
+ * the per-event outcomes. Lets a hidden, disconnected page deliver pending
107
+ * edits and learn about rejections; without it the page beacons once. */
108
+ postEvents?(body: {
109
+ clientGuid: string;
110
+ events: ClientEvent<C>[];
111
+ }, signal: AbortSignal): Promise<{
112
+ outcomes: HttpEventOutcome[];
113
+ }>;
98
114
  }
99
115
  export declare class SyncClient<B, C extends EventBody, S extends EventBody, Summary = unknown> {
100
116
  private readonly options;
@@ -111,7 +127,11 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
111
127
  private readonly abortControllers;
112
128
  private readonly removeListeners;
113
129
  private currentState;
130
+ /** Failed attempts in the current visible spell: drives the ladder and the
131
+ * outage log's `attempts` (what the user could have waited through). */
114
132
  private reconnectAttempts;
133
+ /** Failed visible attempts across the whole outage (every spell). */
134
+ private totalAttempts;
115
135
  private reconnectTimer;
116
136
  private countdownTimer;
117
137
  private pingTimer;
@@ -134,6 +154,23 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
134
154
  private reconciliationTail;
135
155
  private initialization;
136
156
  private disposed;
157
+ private hiddenSince;
158
+ private hiddenSocketFailed;
159
+ private beaconedWhileDormant;
160
+ private backgroundTickTimer;
161
+ private lastTickAt;
162
+ private catchupSlot;
163
+ private readonly httpApplied;
164
+ private connectReplyTimer;
165
+ private socketCreatedHidden;
166
+ private socketOpenedAt;
167
+ private plannedClose;
168
+ private outageCloseCode;
169
+ private outageInfo;
170
+ private lastContactAt;
171
+ private spellCounters;
172
+ private returnWindow;
173
+ private readonly counters;
137
174
  constructor(options: SyncClientOptions<B, C, S, Summary>);
138
175
  init(): Promise<void>;
139
176
  private initialize;
@@ -154,15 +191,41 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
154
191
  state(): SyncClientState;
155
192
  subscribe(listener: (state: SyncClientState) => void): () => void;
156
193
  private installRuntimeListeners;
194
+ private isHidden;
195
+ private now;
196
+ /** Counters with the running hidden spell's time folded in. */
197
+ private snapshot;
198
+ private static delta;
199
+ private onBecameHidden;
200
+ private onBecameVisible;
201
+ private onOnline;
157
202
  private wakeConnection;
158
203
  /** Drops the current socket, if any, without treating it as an outage, and
159
204
  * connects at once. */
160
205
  private replaceSocket;
161
206
  private connect;
207
+ /** Hidden and disconnected: no WebSocket retries; HTTP catch-ups on a slow
208
+ * cadence instead (docs/SYNC-BACKGROUND-PLAN.md). */
209
+ private enterDormant;
210
+ private cancelBackgroundTick;
211
+ private backgroundTick;
212
+ private unsettledPending;
213
+ private postPendingOverHttp;
214
+ /** The background/return catch-up (review S5): one slot, a return aborts
215
+ * a background fetch in flight, both give up after 15 s. */
216
+ private runCatchup;
217
+ private withTimeout;
218
+ private openReturnWindow;
219
+ private maybeFinishReturnWindow;
220
+ /** One INFO per hidden spell: what the user saw change on return. */
221
+ private finishReturnWindow;
162
222
  private connectNow;
163
223
  private scheduleReconnect;
164
224
  private probeSession;
165
225
  private startOutage;
226
+ /** ERROR only after OUTAGE_ESCALATE_MS of visible, on-time outage; re-armed
227
+ * on each return so an outage that began hidden still escalates (S3). */
228
+ private armEscalation;
166
229
  private handleMessage;
167
230
  private handleSocketCatchup;
168
231
  /** Returns false when a forward gap prevented application. */
@@ -174,6 +237,9 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
174
237
  private finishConfirmation;
175
238
  private finishRejection;
176
239
  private reconcileStore;
240
+ private armFailureTimer;
241
+ /** On return: a verdict needs 60 s of visible time (review B2). */
242
+ private rearmFailureTimers;
177
243
  private failUnconfirmed;
178
244
  private onSyncEstablished;
179
245
  private requestReload;
@@ -15,6 +15,12 @@ const GAP_BUFFER_MAX = 1_000;
15
15
  const RECONCILE_MOVED_MAX_REFETCHES = 3;
16
16
  const CLOSE_NO_USER = 4403;
17
17
  const PROBE_AFTER_ATTEMPTS = 2;
18
+ const DEFAULT_BACKGROUND_CATCHUP_MS = 5 * 60 * 1000;
19
+ const BACKGROUND_FETCH_TIMEOUT_MS = 15_000;
20
+ const WAKE_CONNECT_REPLY_MS = 5_000;
21
+ /** A hidden attempt's socket that dies sooner than this counts as failed. */
22
+ const HIDDEN_SOCKET_MIN_LIFE_MS = 60_000;
23
+ const RETURN_REPORT_TIMEOUT_MS = 15_000;
18
24
  const SOCKET_CONNECTING = 0;
19
25
  const SOCKET_OPEN = 1;
20
26
  class LifecycleAbort {
@@ -41,7 +47,11 @@ export class SyncClient {
41
47
  abortControllers = new Set();
42
48
  removeListeners = [];
43
49
  currentState = { kind: 'starting', pending: 0 };
50
+ /** Failed attempts in the current visible spell: drives the ladder and the
51
+ * outage log's `attempts` (what the user could have waited through). */
44
52
  reconnectAttempts = 0;
53
+ /** Failed visible attempts across the whole outage (every spell). */
54
+ totalAttempts = 0;
45
55
  reconnectTimer = null;
46
56
  countdownTimer = null;
47
57
  pingTimer = null;
@@ -64,6 +74,32 @@ export class SyncClient {
64
74
  reconciliationTail = Promise.resolve();
65
75
  initialization = null;
66
76
  disposed = false;
77
+ // Hidden-page state (docs/SYNC-BACKGROUND-PLAN.md).
78
+ hiddenSince = null;
79
+ hiddenSocketFailed = false;
80
+ beaconedWhileDormant = false;
81
+ backgroundTickTimer = null;
82
+ lastTickAt = null;
83
+ catchupSlot = null;
84
+ httpApplied = new Set();
85
+ connectReplyTimer = null;
86
+ socketCreatedHidden = false;
87
+ socketOpenedAt = null;
88
+ plannedClose = null;
89
+ outageCloseCode = 1006;
90
+ outageInfo = null;
91
+ lastContactAt = null;
92
+ spellCounters = null;
93
+ returnWindow = null;
94
+ counters = {
95
+ hiddenMs: 0,
96
+ freezes: 0,
97
+ resumes: 0,
98
+ watchdogKills: 0,
99
+ backgroundCatchups: 0,
100
+ backgroundCatchupFailures: 0,
101
+ backgroundSocketAttempts: 0,
102
+ };
67
103
  constructor(options) {
68
104
  this.options = options;
69
105
  }
@@ -178,7 +214,7 @@ export class SyncClient {
178
214
  const entry = { ...persisted, status: 'pending' };
179
215
  this.pending.set(event.eventGuid, entry);
180
216
  this.options.store.applyOptimistic(event);
181
- this.failureTimers.set(event.eventGuid, this.options.runtime.setTimeout(() => this.failUnconfirmed(event.eventGuid), FAILURE_TIMEOUT_MS));
217
+ this.armFailureTimer(event.eventGuid);
182
218
  this.trySend(entry);
183
219
  this.updatePendingState();
184
220
  this.options.onSafetyChange?.();
@@ -219,10 +255,100 @@ export class SyncClient {
219
255
  return () => this.subscribers.delete(listener);
220
256
  }
221
257
  installRuntimeListeners() {
222
- this.removeListeners.push(this.options.runtime.onVisibilityChange(() => {
223
- if (this.options.runtime.visibility() === 'visible')
224
- this.wakeConnection();
225
- }), this.options.runtime.onOnline(() => this.wakeConnection()), this.options.runtime.onPageHide(() => this.flushViaBeacon()));
258
+ const runtime = this.options.runtime;
259
+ // Loaded hidden (a background tab): the spell starts now; the first
260
+ // connect still runs, and its failure goes dormant rather than retrying.
261
+ if (runtime.visibility() === 'hidden') {
262
+ this.hiddenSince = this.now();
263
+ this.spellCounters = this.snapshot();
264
+ }
265
+ this.removeListeners.push(runtime.onVisibilityChange(() => {
266
+ if (runtime.visibility() === 'visible')
267
+ this.onBecameVisible();
268
+ else
269
+ this.onBecameHidden();
270
+ }), runtime.onOnline(() => this.onOnline()), runtime.onPageHide(() => this.flushViaBeacon()));
271
+ const onFreeze = runtime.onFreeze?.(() => this.counters.freezes++);
272
+ const onResume = runtime.onResume?.(() => this.counters.resumes++);
273
+ if (onFreeze)
274
+ this.removeListeners.push(onFreeze);
275
+ if (onResume)
276
+ this.removeListeners.push(onResume);
277
+ }
278
+ isHidden() {
279
+ return this.hiddenSince !== null;
280
+ }
281
+ now() {
282
+ return this.options.runtime.now();
283
+ }
284
+ /** Counters with the running hidden spell's time folded in. */
285
+ snapshot() {
286
+ const hiddenMs = this.counters.hiddenMs + (this.hiddenSince === null ? 0 : Math.max(0, this.now() - this.hiddenSince));
287
+ return { ...this.counters, hiddenMs };
288
+ }
289
+ static delta(since, now) {
290
+ return {
291
+ hiddenMs: Math.max(0, now.hiddenMs - since.hiddenMs),
292
+ freezes: now.freezes - since.freezes,
293
+ resumes: now.resumes - since.resumes,
294
+ watchdogKills: now.watchdogKills - since.watchdogKills,
295
+ backgroundCatchups: now.backgroundCatchups - since.backgroundCatchups,
296
+ backgroundCatchupFailures: now.backgroundCatchupFailures - since.backgroundCatchupFailures,
297
+ backgroundSocketAttempts: now.backgroundSocketAttempts - since.backgroundSocketAttempts,
298
+ };
299
+ }
300
+ onBecameHidden() {
301
+ if (this.halted || this.disposed || this.hiddenSince !== null)
302
+ return;
303
+ this.finishReturnWindow();
304
+ this.hiddenSince = this.now();
305
+ this.spellCounters = this.snapshot();
306
+ this.hiddenSocketFailed = false;
307
+ this.beaconedWhileDormant = false;
308
+ if (this.connectReplyTimer !== null)
309
+ this.options.runtime.clearTimeout(this.connectReplyTimer);
310
+ this.connectReplyTimer = null;
311
+ // A ladder waiting to retry is cancelled: no reconnects while hidden.
312
+ if (this.socket === null)
313
+ this.enterDormant();
314
+ }
315
+ onBecameVisible() {
316
+ if (this.halted || this.disposed)
317
+ return;
318
+ const hiddenSince = this.hiddenSince;
319
+ if (hiddenSince !== null) {
320
+ const hiddenMs = Math.max(0, this.now() - hiddenSince);
321
+ const spell = this.spellCounters ?? this.snapshot();
322
+ this.counters.hiddenMs += hiddenMs;
323
+ this.hiddenSince = null;
324
+ this.spellCounters = null;
325
+ this.cancelBackgroundTick();
326
+ this.httpApplied.clear();
327
+ this.openReturnWindow(hiddenMs, spell);
328
+ this.rearmFailureTimers();
329
+ if (this.outageStartedAt !== null && !this.outageEscalated)
330
+ this.armEscalation();
331
+ // Each return starts the ladder afresh: failures from earlier glances
332
+ // (a phone shown for a second, often before its network is back) must
333
+ // not stretch this return's retry to 8-30 s or add up to a WARN.
334
+ this.reconnectAttempts = 0;
335
+ }
336
+ this.wakeConnection();
337
+ // Always: a socket that looks open may be dead (review S2), and a
338
+ // handshake may be slow. One HTTP round trip makes the page current.
339
+ void this.runCatchup('return');
340
+ }
341
+ onOnline() {
342
+ if (this.halted || this.disposed)
343
+ return;
344
+ if (this.isHidden()) {
345
+ if (this.socket === null) {
346
+ this.cancelBackgroundTick();
347
+ void this.backgroundTick();
348
+ }
349
+ return;
350
+ }
351
+ this.wakeConnection();
226
352
  }
227
353
  wakeConnection() {
228
354
  if (this.halted || this.disposed)
@@ -230,6 +356,15 @@ export class SyncClient {
230
356
  const socket = this.socket;
231
357
  if (socket?.readyState === SOCKET_OPEN) {
232
358
  this.sendConnect();
359
+ // A socket that looks open may be half-dead: no catch-up reply in 5 s
360
+ // replaces it.
361
+ if (this.connectReplyTimer !== null)
362
+ this.options.runtime.clearTimeout(this.connectReplyTimer);
363
+ this.connectReplyTimer = this.options.runtime.setTimeout(() => {
364
+ this.connectReplyTimer = null;
365
+ if (this.socket === socket && !this.isHidden())
366
+ this.replaceSocket();
367
+ }, WAKE_CONNECT_REPLY_MS);
233
368
  return;
234
369
  }
235
370
  // A handshake still inside its watchdog window may just be slow: leave
@@ -269,14 +404,23 @@ export class SyncClient {
269
404
  catch (error) {
270
405
  this.options.logger.warn('socket creation failed', { error });
271
406
  this.startOutage(1006);
272
- this.scheduleReconnect();
407
+ if (this.isHidden())
408
+ this.enterDormant();
409
+ else
410
+ this.scheduleReconnect();
273
411
  return;
274
412
  }
275
413
  this.socket = socket;
276
414
  this.socketCreatedAt = this.options.runtime.now();
415
+ this.socketCreatedHidden = this.isHidden();
416
+ this.socketOpenedAt = null;
417
+ if (this.socketCreatedHidden)
418
+ this.counters.backgroundSocketAttempts++;
277
419
  this.watchdogTimer = this.options.runtime.setTimeout(() => {
278
- if (socket.readyState !== SOCKET_OPEN)
420
+ if (socket.readyState !== SOCKET_OPEN) {
421
+ this.counters.watchdogKills++;
279
422
  socket.close();
423
+ }
280
424
  }, CONNECT_WATCHDOG_MS);
281
425
  socket.onopen = () => {
282
426
  if (this.socket !== socket) {
@@ -286,16 +430,21 @@ export class SyncClient {
286
430
  if (this.watchdogTimer !== null)
287
431
  this.options.runtime.clearTimeout(this.watchdogTimer);
288
432
  this.watchdogTimer = null;
433
+ this.socketOpenedAt = this.now();
434
+ this.cancelBackgroundTick();
289
435
  this.sendConnect();
290
436
  this.pingTimer = this.options.runtime.setInterval(() => this.sendRaw({ type: 'ping' }), PING_INTERVAL_MS);
291
437
  this.rolloverTimer = this.options.runtime.setTimeout(() => {
292
438
  this.options.logger.info('ws rollover', {});
439
+ this.plannedClose = socket;
293
440
  socket.close();
294
441
  }, this.options.reconnectBeforeMs ?? DEFAULT_RECONNECT_BEFORE_MS);
295
442
  };
296
443
  socket.onmessage = (event) => {
297
- if (this.socket === socket)
298
- this.handleMessage(String(event.data));
444
+ if (this.socket !== socket)
445
+ return;
446
+ this.lastContactAt = this.now();
447
+ this.handleMessage(String(event.data));
299
448
  };
300
449
  socket.onerror = () => {
301
450
  // Browsers expose no useful detail; the close event owns recovery.
@@ -312,10 +461,219 @@ export class SyncClient {
312
461
  this.halt();
313
462
  return;
314
463
  }
315
- this.startOutage(event.code);
316
- this.scheduleReconnect();
464
+ const planned = this.plannedClose === socket;
465
+ this.plannedClose = null;
466
+ this.startOutage(event.code, event.wasClean === true);
467
+ if (!this.isHidden()) {
468
+ this.scheduleReconnect();
469
+ return;
470
+ }
471
+ if (planned) {
472
+ // Our own rollover: this spell's sockets have lasted; reconnect once.
473
+ this.connectNow();
474
+ return;
475
+ }
476
+ const openedAt = this.socketOpenedAt;
477
+ if (this.socketCreatedHidden && (openedAt === null || this.now() - openedAt < HIDDEN_SOCKET_MIN_LIFE_MS)) {
478
+ this.hiddenSocketFailed = true;
479
+ }
480
+ this.enterDormant();
481
+ };
482
+ }
483
+ /** Hidden and disconnected: no WebSocket retries; HTTP catch-ups on a slow
484
+ * cadence instead (docs/SYNC-BACKGROUND-PLAN.md). */
485
+ enterDormant() {
486
+ if (this.halted || this.disposed)
487
+ return;
488
+ if (this.reconnectTimer !== null)
489
+ this.options.runtime.clearTimeout(this.reconnectTimer);
490
+ if (this.countdownTimer !== null)
491
+ this.options.runtime.clearInterval(this.countdownTimer);
492
+ this.reconnectTimer = null;
493
+ this.countdownTimer = null;
494
+ this.publish({ kind: 'disconnected', pending: this.pendingCount() });
495
+ if (!this.options.postEvents && !this.beaconedWhileDormant && this.unsettledPending().length > 0) {
496
+ this.beaconedWhileDormant = true;
497
+ this.flushViaBeacon();
498
+ }
499
+ if (this.backgroundTickTimer !== null)
500
+ return;
501
+ const interval = this.options.backgroundCatchupMs ?? DEFAULT_BACKGROUND_CATCHUP_MS;
502
+ const due = this.lastTickAt === null ? 0 : Math.max(0, this.lastTickAt + interval - this.now());
503
+ this.backgroundTickTimer = this.options.runtime.setTimeout(() => {
504
+ this.backgroundTickTimer = null;
505
+ void this.backgroundTick();
506
+ }, Math.min(due, interval));
507
+ }
508
+ cancelBackgroundTick() {
509
+ if (this.backgroundTickTimer !== null)
510
+ this.options.runtime.clearTimeout(this.backgroundTickTimer);
511
+ this.backgroundTickTimer = null;
512
+ }
513
+ async backgroundTick() {
514
+ if (this.halted || this.disposed || !this.isHidden() || this.socket !== null)
515
+ return;
516
+ this.lastTickAt = this.now();
517
+ await this.postPendingOverHttp();
518
+ const ok = await this.runCatchup('background');
519
+ if (this.halted || this.disposed || !this.isHidden() || this.socket !== null)
520
+ return;
521
+ if (ok && !this.hiddenSocketFailed) {
522
+ // Desktop hidden sockets usually live; one attempt per tick, none after
523
+ // the first hidden failure in this spell (phones cut them in seconds).
524
+ this.connectNow();
525
+ return;
526
+ }
527
+ this.enterDormant();
528
+ }
529
+ unsettledPending() {
530
+ return [...this.pending.values()].filter((entry) => !this.settledPending.has(entry.event.eventGuid));
531
+ }
532
+ async postPendingOverHttp() {
533
+ const postEvents = this.options.postEvents;
534
+ if (!postEvents)
535
+ return;
536
+ const events = this.unsettledPending()
537
+ .filter((entry) => !this.httpApplied.has(entry.event.eventGuid))
538
+ .map((entry) => entry.event);
539
+ if (events.length === 0)
540
+ return;
541
+ try {
542
+ const { outcomes } = await this.withTimeout((signal) => postEvents({ clientGuid: this.options.clientGuid, events }, signal));
543
+ if (this.stopBeforeStoreMutation())
544
+ return;
545
+ for (const outcome of outcomes) {
546
+ if (outcome.status === 'rejected') {
547
+ this.handleRejection({
548
+ type: 'rejection',
549
+ eventGuid: outcome.eventGuid,
550
+ error: outcome.error ?? { code: 'rejected', message: 'rejected' },
551
+ });
552
+ }
553
+ else {
554
+ // Applied (or a duplicate): the catch-up's echo confirms it.
555
+ this.httpApplied.add(outcome.eventGuid);
556
+ }
557
+ }
558
+ }
559
+ catch {
560
+ // Offline or timed out: the next tick, or the socket on return, retries.
561
+ }
562
+ }
563
+ /** The background/return catch-up (review S5): one slot, a return aborts
564
+ * a background fetch in flight, both give up after 15 s. */
565
+ async runCatchup(kind) {
566
+ if (this.halted || this.disposed)
567
+ return false;
568
+ if (this.catchupSlot !== null) {
569
+ if (kind === 'return' && this.catchupSlot.kind === 'background')
570
+ this.catchupSlot.controller.abort();
571
+ else
572
+ return false;
573
+ }
574
+ const controller = new AbortController();
575
+ const slot = { kind, controller };
576
+ this.catchupSlot = slot;
577
+ this.abortControllers.add(controller);
578
+ const timeout = this.options.runtime.setTimeout(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
579
+ let ok = false;
580
+ try {
581
+ const result = await this.options.fetchCatchup(this.options.store.currentSeq(), controller.signal);
582
+ if (controller.signal.aborted || this.stopBeforeStoreMutation())
583
+ return false;
584
+ ok = true;
585
+ this.lastContactAt = this.now();
586
+ if (result.buildId)
587
+ this.options.onBuildObserved?.(result.buildId);
588
+ else if (kind === 'background')
589
+ this.options.onBuildChanged?.();
590
+ if (result.reload) {
591
+ this.requestReload('server');
592
+ }
593
+ else {
594
+ for (const event of result.events)
595
+ this.handleServerEvent(event);
596
+ }
597
+ return true;
598
+ }
599
+ catch {
600
+ return false;
601
+ }
602
+ finally {
603
+ this.options.runtime.clearTimeout(timeout);
604
+ this.abortControllers.delete(controller);
605
+ if (this.catchupSlot === slot)
606
+ this.catchupSlot = null;
607
+ if (kind === 'background') {
608
+ if (ok)
609
+ this.counters.backgroundCatchups++;
610
+ else
611
+ this.counters.backgroundCatchupFailures++;
612
+ }
613
+ if (kind === 'return' && this.returnWindow !== null) {
614
+ this.returnWindow.catchupDone = true;
615
+ this.returnWindow.catchupOk = ok;
616
+ this.maybeFinishReturnWindow();
617
+ }
618
+ }
619
+ }
620
+ async withTimeout(operation) {
621
+ const controller = new AbortController();
622
+ this.abortControllers.add(controller);
623
+ const timeout = this.options.runtime.setTimeout(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
624
+ try {
625
+ return await operation(controller.signal);
626
+ }
627
+ finally {
628
+ this.options.runtime.clearTimeout(timeout);
629
+ this.abortControllers.delete(controller);
630
+ }
631
+ }
632
+ openReturnWindow(hiddenMs, spell) {
633
+ this.finishReturnWindow();
634
+ const now = this.now();
635
+ this.returnWindow = {
636
+ visibleAt: now,
637
+ hiddenMs,
638
+ socketOpenAtReturn: this.socket?.readyState === SOCKET_OPEN,
639
+ sinceLastContactMs: this.lastContactAt === null ? null : Math.max(0, now - this.lastContactAt),
640
+ spell: SyncClient.delta(spell, this.snapshot()),
641
+ establishedAt: null,
642
+ catchupDone: false,
643
+ catchupOk: false,
644
+ eventsOnReturn: 0,
645
+ resyncOnReturn: 0,
646
+ timer: this.options.runtime.setTimeout(() => this.finishReturnWindow(), RETURN_REPORT_TIMEOUT_MS),
317
647
  };
318
648
  }
649
+ maybeFinishReturnWindow() {
650
+ const window = this.returnWindow;
651
+ if (window !== null && window.establishedAt !== null && window.catchupDone)
652
+ this.finishReturnWindow();
653
+ }
654
+ /** One INFO per hidden spell: what the user saw change on return. */
655
+ finishReturnWindow() {
656
+ const window = this.returnWindow;
657
+ if (window === null)
658
+ return;
659
+ this.returnWindow = null;
660
+ this.options.runtime.clearTimeout(window.timer);
661
+ this.options.logger.info('page returned to foreground', {
662
+ hiddenMs: window.hiddenMs,
663
+ socketOpenAtReturn: window.socketOpenAtReturn,
664
+ reconnectMs: window.establishedAt === null ? null : Math.max(0, window.establishedAt - window.visibleAt),
665
+ catchupOk: window.catchupOk,
666
+ eventsOnReturn: window.eventsOnReturn,
667
+ resyncOnReturn: window.resyncOnReturn,
668
+ sinceLastContactMs: window.sinceLastContactMs,
669
+ backgroundCatchups: window.spell.backgroundCatchups,
670
+ backgroundCatchupFailures: window.spell.backgroundCatchupFailures,
671
+ backgroundSocketAttempts: window.spell.backgroundSocketAttempts,
672
+ watchdogKills: window.spell.watchdogKills,
673
+ freezes: window.spell.freezes,
674
+ resumes: window.spell.resumes,
675
+ });
676
+ }
319
677
  connectNow() {
320
678
  if (this.reconnectTimer !== null)
321
679
  this.options.runtime.clearTimeout(this.reconnectTimer);
@@ -328,6 +686,7 @@ export class SyncClient {
328
686
  scheduleReconnect() {
329
687
  const delay = RECONNECT_DELAYS_SECONDS[Math.min(this.reconnectAttempts, RECONNECT_DELAYS_SECONDS.length - 1)];
330
688
  this.reconnectAttempts++;
689
+ this.totalAttempts++;
331
690
  if (this.reconnectAttempts >= PROBE_AFTER_ATTEMPTS)
332
691
  void this.probeSession();
333
692
  let remaining = delay;
@@ -371,13 +730,23 @@ export class SyncClient {
371
730
  this.probeInFlight = false;
372
731
  }
373
732
  }
374
- startOutage(closeCode) {
733
+ startOutage(closeCode, wasClean = false) {
375
734
  if (this.outageStartedAt !== null)
376
735
  return;
377
736
  this.outageStartedAt = this.options.runtime.now();
378
737
  this.outageEscalated = false;
738
+ this.outageCloseCode = closeCode;
739
+ this.outageInfo = { closeCode, wasClean, visibleAtDrop: !this.isHidden(), counters: this.snapshot() };
740
+ this.armEscalation();
741
+ }
742
+ /** ERROR only after OUTAGE_ESCALATE_MS of visible, on-time outage; re-armed
743
+ * on each return so an outage that began hidden still escalates (S3). */
744
+ armEscalation() {
745
+ if (this.escalateTimer !== null)
746
+ this.options.runtime.clearTimeout(this.escalateTimer);
379
747
  this.escalateArmedAt = this.options.runtime.now();
380
748
  this.escalateTimer = this.options.runtime.setTimeout(() => {
749
+ this.escalateTimer = null;
381
750
  const late = this.options.runtime.now() - this.escalateArmedAt - OUTAGE_ESCALATE_MS;
382
751
  if (this.options.runtime.visibility() === 'hidden' || late > 5000)
383
752
  return;
@@ -385,7 +754,7 @@ export class SyncClient {
385
754
  this.outageEscalated = true;
386
755
  this.options.logger.error('sync outage ongoing', {
387
756
  durationMs: this.options.runtime.now() - this.outageStartedAt,
388
- closeCode,
757
+ closeCode: this.outageCloseCode,
389
758
  attempts: this.reconnectAttempts,
390
759
  });
391
760
  }
@@ -415,6 +784,9 @@ export class SyncClient {
415
784
  this.handleSocketCatchup(message);
416
785
  return;
417
786
  case 'reload':
787
+ if (this.connectReplyTimer !== null)
788
+ this.options.runtime.clearTimeout(this.connectReplyTimer);
789
+ this.connectReplyTimer = null;
418
790
  this.requestReload('server');
419
791
  return;
420
792
  case 'event':
@@ -426,6 +798,9 @@ export class SyncClient {
426
798
  }
427
799
  }
428
800
  handleSocketCatchup(message) {
801
+ if (this.connectReplyTimer !== null)
802
+ this.options.runtime.clearTimeout(this.connectReplyTimer);
803
+ this.connectReplyTimer = null;
429
804
  if (message.buildId)
430
805
  this.options.onBuildObserved?.(message.buildId);
431
806
  let complete = true;
@@ -446,8 +821,12 @@ export class SyncClient {
446
821
  return false;
447
822
  const currentSeq = this.options.store.currentSeq();
448
823
  if (event.seq <= currentSeq) {
449
- if (this.pending.has(event.eventGuid))
824
+ // A second echo of an already-confirmed edit (socket and HTTP catch-up
825
+ // both deliver it on return) must not upgrade the settlement to a full
826
+ // reconcile (review S4).
827
+ if (this.pending.has(event.eventGuid) && !this.confirmedPending.has(event.eventGuid)) {
450
828
  this.confirmPending(event.eventGuid, true);
829
+ }
451
830
  return true;
452
831
  }
453
832
  if (event.seq > currentSeq + 1) {
@@ -470,6 +849,8 @@ export class SyncClient {
470
849
  return false;
471
850
  }
472
851
  this.options.store.applyServerEvent(event);
852
+ if (this.returnWindow !== null && !this.pending.has(event.eventGuid))
853
+ this.returnWindow.eventsOnReturn++;
473
854
  if (this.stopBeforeStoreMutation())
474
855
  return false;
475
856
  this.options.store.setCurrentSeq(event.seq);
@@ -641,6 +1022,8 @@ export class SyncClient {
641
1022
  return true;
642
1023
  }
643
1024
  this.options.store.installBootstrap(bootstrap);
1025
+ if (this.returnWindow !== null)
1026
+ this.returnWindow.resyncOnReturn++;
644
1027
  for (const [eventGuid, entry] of this.pending) {
645
1028
  if (this.settledPending.has(eventGuid))
646
1029
  continue;
@@ -655,7 +1038,24 @@ export class SyncClient {
655
1038
  release();
656
1039
  }
657
1040
  }
1041
+ armFailureTimer(eventGuid) {
1042
+ this.clearFailureTimer(eventGuid);
1043
+ this.failureTimers.set(eventGuid, this.options.runtime.setTimeout(() => this.failUnconfirmed(eventGuid), FAILURE_TIMEOUT_MS));
1044
+ }
1045
+ /** On return: a verdict needs 60 s of visible time (review B2). */
1046
+ rearmFailureTimers() {
1047
+ for (const [eventGuid, entry] of this.pending) {
1048
+ if (entry.recovered || this.settledPending.has(eventGuid) || this.unresolvedFailures.has(eventGuid))
1049
+ continue;
1050
+ this.armFailureTimer(eventGuid);
1051
+ }
1052
+ }
658
1053
  failUnconfirmed(eventGuid) {
1054
+ this.failureTimers.delete(eventGuid);
1055
+ // Hidden: no verdict and no re-arm (that would wake the page every
1056
+ // minute); the return re-arms a full 60 s.
1057
+ if (this.isHidden())
1058
+ return;
659
1059
  const entry = this.pending.get(eventGuid);
660
1060
  if (!entry || entry.recovered)
661
1061
  return;
@@ -667,24 +1067,51 @@ export class SyncClient {
667
1067
  }
668
1068
  onSyncEstablished() {
669
1069
  if (this.outageStartedAt !== null) {
670
- const durationMs = this.options.runtime.now() - this.outageStartedAt;
1070
+ const durationMs = Math.max(0, this.options.runtime.now() - this.outageStartedAt);
1071
+ // Failures are only counted (and the ladder only runs) while visible,
1072
+ // and the count restarts on each return, so `attempts` is what the
1073
+ // user could have waited through; `totalAttempts` spans every spell.
671
1074
  const attempts = this.reconnectAttempts;
1075
+ const info = this.outageInfo;
1076
+ const delta = info ? SyncClient.delta(info.counters, this.snapshot()) : null;
1077
+ const fields = {
1078
+ durationMs,
1079
+ attempts,
1080
+ totalAttempts: this.totalAttempts,
1081
+ closeCode: info?.closeCode,
1082
+ wasClean: info?.wasClean,
1083
+ visibleAtDrop: info?.visibleAtDrop,
1084
+ hiddenMs: delta?.hiddenMs,
1085
+ watchdogKills: delta?.watchdogKills,
1086
+ backgroundCatchups: delta?.backgroundCatchups,
1087
+ freezes: delta?.freezes,
1088
+ resumes: delta?.resumes,
1089
+ };
672
1090
  if (this.outageEscalated) {
673
- this.options.logger.info('sync outage recovered', { durationMs, attempts });
1091
+ this.options.logger.info('sync outage recovered', fields);
674
1092
  }
675
1093
  else if (attempts >= 2) {
676
- this.options.logger.warn('sync outage recovered', { durationMs, attempts });
1094
+ this.options.logger.warn('sync outage recovered', fields);
677
1095
  }
678
1096
  else if (attempts === 1) {
679
- this.options.logger.info('ws reconnected first try', { durationMs });
1097
+ this.options.logger.info('ws reconnected first try', fields);
1098
+ }
1099
+ else {
1100
+ this.options.logger.info('background sync outage recovered', fields);
680
1101
  }
681
1102
  this.outageStartedAt = null;
682
1103
  this.outageEscalated = false;
1104
+ this.outageInfo = null;
1105
+ }
1106
+ if (this.returnWindow !== null && this.returnWindow.establishedAt === null) {
1107
+ this.returnWindow.establishedAt = this.now();
1108
+ this.maybeFinishReturnWindow();
683
1109
  }
684
1110
  if (this.escalateTimer !== null)
685
1111
  this.options.runtime.clearTimeout(this.escalateTimer);
686
1112
  this.escalateTimer = null;
687
1113
  this.reconnectAttempts = 0;
1114
+ this.totalAttempts = 0;
688
1115
  for (const [eventGuid, entry] of this.pending) {
689
1116
  if (this.settledPending.has(eventGuid)) {
690
1117
  this.retrySettlement(eventGuid);
@@ -900,6 +1327,9 @@ export class SyncClient {
900
1327
  this.publish({ kind: 'expired', pending: this.pendingCount() });
901
1328
  }
902
1329
  clearConnectionTimers() {
1330
+ if (this.connectReplyTimer !== null)
1331
+ this.options.runtime.clearTimeout(this.connectReplyTimer);
1332
+ this.connectReplyTimer = null;
903
1333
  if (this.pingTimer !== null)
904
1334
  this.options.runtime.clearInterval(this.pingTimer);
905
1335
  if (this.watchdogTimer !== null)
@@ -922,6 +1352,10 @@ export class SyncClient {
922
1352
  this.options.runtime.clearTimeout(this.resyncBackoffTimer);
923
1353
  for (const timer of this.failureTimers.values())
924
1354
  this.options.runtime.clearTimeout(timer);
1355
+ this.cancelBackgroundTick();
1356
+ if (this.returnWindow !== null)
1357
+ this.options.runtime.clearTimeout(this.returnWindow.timer);
1358
+ this.returnWindow = null;
925
1359
  this.reconnectTimer = null;
926
1360
  this.countdownTimer = null;
927
1361
  this.escalateTimer = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.19.1",
3
+ "version": "3.20.1",
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",