@steve31415/baselib 3.19.1 → 3.20.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.
@@ -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;
@@ -134,6 +150,23 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
134
150
  private reconciliationTail;
135
151
  private initialization;
136
152
  private disposed;
153
+ private hiddenSince;
154
+ private hiddenSocketFailed;
155
+ private beaconedWhileDormant;
156
+ private backgroundTickTimer;
157
+ private lastTickAt;
158
+ private catchupSlot;
159
+ private readonly httpApplied;
160
+ private connectReplyTimer;
161
+ private socketCreatedHidden;
162
+ private socketOpenedAt;
163
+ private plannedClose;
164
+ private outageCloseCode;
165
+ private outageInfo;
166
+ private lastContactAt;
167
+ private spellCounters;
168
+ private returnWindow;
169
+ private readonly counters;
137
170
  constructor(options: SyncClientOptions<B, C, S, Summary>);
138
171
  init(): Promise<void>;
139
172
  private initialize;
@@ -154,15 +187,41 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
154
187
  state(): SyncClientState;
155
188
  subscribe(listener: (state: SyncClientState) => void): () => void;
156
189
  private installRuntimeListeners;
190
+ private isHidden;
191
+ private now;
192
+ /** Counters with the running hidden spell's time folded in. */
193
+ private snapshot;
194
+ private static delta;
195
+ private onBecameHidden;
196
+ private onBecameVisible;
197
+ private onOnline;
157
198
  private wakeConnection;
158
199
  /** Drops the current socket, if any, without treating it as an outage, and
159
200
  * connects at once. */
160
201
  private replaceSocket;
161
202
  private connect;
203
+ /** Hidden and disconnected: no WebSocket retries; HTTP catch-ups on a slow
204
+ * cadence instead (docs/SYNC-BACKGROUND-PLAN.md). */
205
+ private enterDormant;
206
+ private cancelBackgroundTick;
207
+ private backgroundTick;
208
+ private unsettledPending;
209
+ private postPendingOverHttp;
210
+ /** The background/return catch-up (review S5): one slot, a return aborts
211
+ * a background fetch in flight, both give up after 15 s. */
212
+ private runCatchup;
213
+ private withTimeout;
214
+ private openReturnWindow;
215
+ private maybeFinishReturnWindow;
216
+ /** One INFO per hidden spell: what the user saw change on return. */
217
+ private finishReturnWindow;
162
218
  private connectNow;
163
219
  private scheduleReconnect;
164
220
  private probeSession;
165
221
  private startOutage;
222
+ /** ERROR only after OUTAGE_ESCALATE_MS of visible, on-time outage; re-armed
223
+ * on each return so an outage that began hidden still escalates (S3). */
224
+ private armEscalation;
166
225
  private handleMessage;
167
226
  private handleSocketCatchup;
168
227
  /** Returns false when a forward gap prevented application. */
@@ -174,6 +233,9 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
174
233
  private finishConfirmation;
175
234
  private finishRejection;
176
235
  private reconcileStore;
236
+ private armFailureTimer;
237
+ /** On return: a verdict needs 60 s of visible time (review B2). */
238
+ private rearmFailureTimers;
177
239
  private failUnconfirmed;
178
240
  private onSyncEstablished;
179
241
  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 {
@@ -64,6 +70,32 @@ export class SyncClient {
64
70
  reconciliationTail = Promise.resolve();
65
71
  initialization = null;
66
72
  disposed = false;
73
+ // Hidden-page state (docs/SYNC-BACKGROUND-PLAN.md).
74
+ hiddenSince = null;
75
+ hiddenSocketFailed = false;
76
+ beaconedWhileDormant = false;
77
+ backgroundTickTimer = null;
78
+ lastTickAt = null;
79
+ catchupSlot = null;
80
+ httpApplied = new Set();
81
+ connectReplyTimer = null;
82
+ socketCreatedHidden = false;
83
+ socketOpenedAt = null;
84
+ plannedClose = null;
85
+ outageCloseCode = 1006;
86
+ outageInfo = null;
87
+ lastContactAt = null;
88
+ spellCounters = null;
89
+ returnWindow = null;
90
+ counters = {
91
+ hiddenMs: 0,
92
+ freezes: 0,
93
+ resumes: 0,
94
+ watchdogKills: 0,
95
+ backgroundCatchups: 0,
96
+ backgroundCatchupFailures: 0,
97
+ backgroundSocketAttempts: 0,
98
+ };
67
99
  constructor(options) {
68
100
  this.options = options;
69
101
  }
@@ -178,7 +210,7 @@ export class SyncClient {
178
210
  const entry = { ...persisted, status: 'pending' };
179
211
  this.pending.set(event.eventGuid, entry);
180
212
  this.options.store.applyOptimistic(event);
181
- this.failureTimers.set(event.eventGuid, this.options.runtime.setTimeout(() => this.failUnconfirmed(event.eventGuid), FAILURE_TIMEOUT_MS));
213
+ this.armFailureTimer(event.eventGuid);
182
214
  this.trySend(entry);
183
215
  this.updatePendingState();
184
216
  this.options.onSafetyChange?.();
@@ -219,10 +251,96 @@ export class SyncClient {
219
251
  return () => this.subscribers.delete(listener);
220
252
  }
221
253
  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()));
254
+ const runtime = this.options.runtime;
255
+ // Loaded hidden (a background tab): the spell starts now; the first
256
+ // connect still runs, and its failure goes dormant rather than retrying.
257
+ if (runtime.visibility() === 'hidden') {
258
+ this.hiddenSince = this.now();
259
+ this.spellCounters = this.snapshot();
260
+ }
261
+ this.removeListeners.push(runtime.onVisibilityChange(() => {
262
+ if (runtime.visibility() === 'visible')
263
+ this.onBecameVisible();
264
+ else
265
+ this.onBecameHidden();
266
+ }), runtime.onOnline(() => this.onOnline()), runtime.onPageHide(() => this.flushViaBeacon()));
267
+ const onFreeze = runtime.onFreeze?.(() => this.counters.freezes++);
268
+ const onResume = runtime.onResume?.(() => this.counters.resumes++);
269
+ if (onFreeze)
270
+ this.removeListeners.push(onFreeze);
271
+ if (onResume)
272
+ this.removeListeners.push(onResume);
273
+ }
274
+ isHidden() {
275
+ return this.hiddenSince !== null;
276
+ }
277
+ now() {
278
+ return this.options.runtime.now();
279
+ }
280
+ /** Counters with the running hidden spell's time folded in. */
281
+ snapshot() {
282
+ const hiddenMs = this.counters.hiddenMs + (this.hiddenSince === null ? 0 : Math.max(0, this.now() - this.hiddenSince));
283
+ return { ...this.counters, hiddenMs };
284
+ }
285
+ static delta(since, now) {
286
+ return {
287
+ hiddenMs: Math.max(0, now.hiddenMs - since.hiddenMs),
288
+ freezes: now.freezes - since.freezes,
289
+ resumes: now.resumes - since.resumes,
290
+ watchdogKills: now.watchdogKills - since.watchdogKills,
291
+ backgroundCatchups: now.backgroundCatchups - since.backgroundCatchups,
292
+ backgroundCatchupFailures: now.backgroundCatchupFailures - since.backgroundCatchupFailures,
293
+ backgroundSocketAttempts: now.backgroundSocketAttempts - since.backgroundSocketAttempts,
294
+ };
295
+ }
296
+ onBecameHidden() {
297
+ if (this.halted || this.disposed || this.hiddenSince !== null)
298
+ return;
299
+ this.finishReturnWindow();
300
+ this.hiddenSince = this.now();
301
+ this.spellCounters = this.snapshot();
302
+ this.hiddenSocketFailed = false;
303
+ this.beaconedWhileDormant = false;
304
+ if (this.connectReplyTimer !== null)
305
+ this.options.runtime.clearTimeout(this.connectReplyTimer);
306
+ this.connectReplyTimer = null;
307
+ // A ladder waiting to retry is cancelled: no reconnects while hidden.
308
+ if (this.socket === null)
309
+ this.enterDormant();
310
+ }
311
+ onBecameVisible() {
312
+ if (this.halted || this.disposed)
313
+ return;
314
+ const hiddenSince = this.hiddenSince;
315
+ if (hiddenSince !== null) {
316
+ const hiddenMs = Math.max(0, this.now() - hiddenSince);
317
+ const spell = this.spellCounters ?? this.snapshot();
318
+ this.counters.hiddenMs += hiddenMs;
319
+ this.hiddenSince = null;
320
+ this.spellCounters = null;
321
+ this.cancelBackgroundTick();
322
+ this.httpApplied.clear();
323
+ this.openReturnWindow(hiddenMs, spell);
324
+ this.rearmFailureTimers();
325
+ if (this.outageStartedAt !== null && !this.outageEscalated)
326
+ this.armEscalation();
327
+ }
328
+ this.wakeConnection();
329
+ // Always: a socket that looks open may be dead (review S2), and a
330
+ // handshake may be slow. One HTTP round trip makes the page current.
331
+ void this.runCatchup('return');
332
+ }
333
+ onOnline() {
334
+ if (this.halted || this.disposed)
335
+ return;
336
+ if (this.isHidden()) {
337
+ if (this.socket === null) {
338
+ this.cancelBackgroundTick();
339
+ void this.backgroundTick();
340
+ }
341
+ return;
342
+ }
343
+ this.wakeConnection();
226
344
  }
227
345
  wakeConnection() {
228
346
  if (this.halted || this.disposed)
@@ -230,6 +348,15 @@ export class SyncClient {
230
348
  const socket = this.socket;
231
349
  if (socket?.readyState === SOCKET_OPEN) {
232
350
  this.sendConnect();
351
+ // A socket that looks open may be half-dead: no catch-up reply in 5 s
352
+ // replaces it.
353
+ if (this.connectReplyTimer !== null)
354
+ this.options.runtime.clearTimeout(this.connectReplyTimer);
355
+ this.connectReplyTimer = this.options.runtime.setTimeout(() => {
356
+ this.connectReplyTimer = null;
357
+ if (this.socket === socket && !this.isHidden())
358
+ this.replaceSocket();
359
+ }, WAKE_CONNECT_REPLY_MS);
233
360
  return;
234
361
  }
235
362
  // A handshake still inside its watchdog window may just be slow: leave
@@ -269,14 +396,23 @@ export class SyncClient {
269
396
  catch (error) {
270
397
  this.options.logger.warn('socket creation failed', { error });
271
398
  this.startOutage(1006);
272
- this.scheduleReconnect();
399
+ if (this.isHidden())
400
+ this.enterDormant();
401
+ else
402
+ this.scheduleReconnect();
273
403
  return;
274
404
  }
275
405
  this.socket = socket;
276
406
  this.socketCreatedAt = this.options.runtime.now();
407
+ this.socketCreatedHidden = this.isHidden();
408
+ this.socketOpenedAt = null;
409
+ if (this.socketCreatedHidden)
410
+ this.counters.backgroundSocketAttempts++;
277
411
  this.watchdogTimer = this.options.runtime.setTimeout(() => {
278
- if (socket.readyState !== SOCKET_OPEN)
412
+ if (socket.readyState !== SOCKET_OPEN) {
413
+ this.counters.watchdogKills++;
279
414
  socket.close();
415
+ }
280
416
  }, CONNECT_WATCHDOG_MS);
281
417
  socket.onopen = () => {
282
418
  if (this.socket !== socket) {
@@ -286,16 +422,21 @@ export class SyncClient {
286
422
  if (this.watchdogTimer !== null)
287
423
  this.options.runtime.clearTimeout(this.watchdogTimer);
288
424
  this.watchdogTimer = null;
425
+ this.socketOpenedAt = this.now();
426
+ this.cancelBackgroundTick();
289
427
  this.sendConnect();
290
428
  this.pingTimer = this.options.runtime.setInterval(() => this.sendRaw({ type: 'ping' }), PING_INTERVAL_MS);
291
429
  this.rolloverTimer = this.options.runtime.setTimeout(() => {
292
430
  this.options.logger.info('ws rollover', {});
431
+ this.plannedClose = socket;
293
432
  socket.close();
294
433
  }, this.options.reconnectBeforeMs ?? DEFAULT_RECONNECT_BEFORE_MS);
295
434
  };
296
435
  socket.onmessage = (event) => {
297
- if (this.socket === socket)
298
- this.handleMessage(String(event.data));
436
+ if (this.socket !== socket)
437
+ return;
438
+ this.lastContactAt = this.now();
439
+ this.handleMessage(String(event.data));
299
440
  };
300
441
  socket.onerror = () => {
301
442
  // Browsers expose no useful detail; the close event owns recovery.
@@ -312,10 +453,219 @@ export class SyncClient {
312
453
  this.halt();
313
454
  return;
314
455
  }
315
- this.startOutage(event.code);
316
- this.scheduleReconnect();
456
+ const planned = this.plannedClose === socket;
457
+ this.plannedClose = null;
458
+ this.startOutage(event.code, event.wasClean === true);
459
+ if (!this.isHidden()) {
460
+ this.scheduleReconnect();
461
+ return;
462
+ }
463
+ if (planned) {
464
+ // Our own rollover: this spell's sockets have lasted; reconnect once.
465
+ this.connectNow();
466
+ return;
467
+ }
468
+ const openedAt = this.socketOpenedAt;
469
+ if (this.socketCreatedHidden && (openedAt === null || this.now() - openedAt < HIDDEN_SOCKET_MIN_LIFE_MS)) {
470
+ this.hiddenSocketFailed = true;
471
+ }
472
+ this.enterDormant();
473
+ };
474
+ }
475
+ /** Hidden and disconnected: no WebSocket retries; HTTP catch-ups on a slow
476
+ * cadence instead (docs/SYNC-BACKGROUND-PLAN.md). */
477
+ enterDormant() {
478
+ if (this.halted || this.disposed)
479
+ return;
480
+ if (this.reconnectTimer !== null)
481
+ this.options.runtime.clearTimeout(this.reconnectTimer);
482
+ if (this.countdownTimer !== null)
483
+ this.options.runtime.clearInterval(this.countdownTimer);
484
+ this.reconnectTimer = null;
485
+ this.countdownTimer = null;
486
+ this.publish({ kind: 'disconnected', pending: this.pendingCount() });
487
+ if (!this.options.postEvents && !this.beaconedWhileDormant && this.unsettledPending().length > 0) {
488
+ this.beaconedWhileDormant = true;
489
+ this.flushViaBeacon();
490
+ }
491
+ if (this.backgroundTickTimer !== null)
492
+ return;
493
+ const interval = this.options.backgroundCatchupMs ?? DEFAULT_BACKGROUND_CATCHUP_MS;
494
+ const due = this.lastTickAt === null ? 0 : Math.max(0, this.lastTickAt + interval - this.now());
495
+ this.backgroundTickTimer = this.options.runtime.setTimeout(() => {
496
+ this.backgroundTickTimer = null;
497
+ void this.backgroundTick();
498
+ }, Math.min(due, interval));
499
+ }
500
+ cancelBackgroundTick() {
501
+ if (this.backgroundTickTimer !== null)
502
+ this.options.runtime.clearTimeout(this.backgroundTickTimer);
503
+ this.backgroundTickTimer = null;
504
+ }
505
+ async backgroundTick() {
506
+ if (this.halted || this.disposed || !this.isHidden() || this.socket !== null)
507
+ return;
508
+ this.lastTickAt = this.now();
509
+ await this.postPendingOverHttp();
510
+ const ok = await this.runCatchup('background');
511
+ if (this.halted || this.disposed || !this.isHidden() || this.socket !== null)
512
+ return;
513
+ if (ok && !this.hiddenSocketFailed) {
514
+ // Desktop hidden sockets usually live; one attempt per tick, none after
515
+ // the first hidden failure in this spell (phones cut them in seconds).
516
+ this.connectNow();
517
+ return;
518
+ }
519
+ this.enterDormant();
520
+ }
521
+ unsettledPending() {
522
+ return [...this.pending.values()].filter((entry) => !this.settledPending.has(entry.event.eventGuid));
523
+ }
524
+ async postPendingOverHttp() {
525
+ const postEvents = this.options.postEvents;
526
+ if (!postEvents)
527
+ return;
528
+ const events = this.unsettledPending()
529
+ .filter((entry) => !this.httpApplied.has(entry.event.eventGuid))
530
+ .map((entry) => entry.event);
531
+ if (events.length === 0)
532
+ return;
533
+ try {
534
+ const { outcomes } = await this.withTimeout((signal) => postEvents({ clientGuid: this.options.clientGuid, events }, signal));
535
+ if (this.stopBeforeStoreMutation())
536
+ return;
537
+ for (const outcome of outcomes) {
538
+ if (outcome.status === 'rejected') {
539
+ this.handleRejection({
540
+ type: 'rejection',
541
+ eventGuid: outcome.eventGuid,
542
+ error: outcome.error ?? { code: 'rejected', message: 'rejected' },
543
+ });
544
+ }
545
+ else {
546
+ // Applied (or a duplicate): the catch-up's echo confirms it.
547
+ this.httpApplied.add(outcome.eventGuid);
548
+ }
549
+ }
550
+ }
551
+ catch {
552
+ // Offline or timed out: the next tick, or the socket on return, retries.
553
+ }
554
+ }
555
+ /** The background/return catch-up (review S5): one slot, a return aborts
556
+ * a background fetch in flight, both give up after 15 s. */
557
+ async runCatchup(kind) {
558
+ if (this.halted || this.disposed)
559
+ return false;
560
+ if (this.catchupSlot !== null) {
561
+ if (kind === 'return' && this.catchupSlot.kind === 'background')
562
+ this.catchupSlot.controller.abort();
563
+ else
564
+ return false;
565
+ }
566
+ const controller = new AbortController();
567
+ const slot = { kind, controller };
568
+ this.catchupSlot = slot;
569
+ this.abortControllers.add(controller);
570
+ const timeout = this.options.runtime.setTimeout(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
571
+ let ok = false;
572
+ try {
573
+ const result = await this.options.fetchCatchup(this.options.store.currentSeq(), controller.signal);
574
+ if (controller.signal.aborted || this.stopBeforeStoreMutation())
575
+ return false;
576
+ ok = true;
577
+ this.lastContactAt = this.now();
578
+ if (result.buildId)
579
+ this.options.onBuildObserved?.(result.buildId);
580
+ else if (kind === 'background')
581
+ this.options.onBuildChanged?.();
582
+ if (result.reload) {
583
+ this.requestReload('server');
584
+ }
585
+ else {
586
+ for (const event of result.events)
587
+ this.handleServerEvent(event);
588
+ }
589
+ return true;
590
+ }
591
+ catch {
592
+ return false;
593
+ }
594
+ finally {
595
+ this.options.runtime.clearTimeout(timeout);
596
+ this.abortControllers.delete(controller);
597
+ if (this.catchupSlot === slot)
598
+ this.catchupSlot = null;
599
+ if (kind === 'background') {
600
+ if (ok)
601
+ this.counters.backgroundCatchups++;
602
+ else
603
+ this.counters.backgroundCatchupFailures++;
604
+ }
605
+ if (kind === 'return' && this.returnWindow !== null) {
606
+ this.returnWindow.catchupDone = true;
607
+ this.returnWindow.catchupOk = ok;
608
+ this.maybeFinishReturnWindow();
609
+ }
610
+ }
611
+ }
612
+ async withTimeout(operation) {
613
+ const controller = new AbortController();
614
+ this.abortControllers.add(controller);
615
+ const timeout = this.options.runtime.setTimeout(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
616
+ try {
617
+ return await operation(controller.signal);
618
+ }
619
+ finally {
620
+ this.options.runtime.clearTimeout(timeout);
621
+ this.abortControllers.delete(controller);
622
+ }
623
+ }
624
+ openReturnWindow(hiddenMs, spell) {
625
+ this.finishReturnWindow();
626
+ const now = this.now();
627
+ this.returnWindow = {
628
+ visibleAt: now,
629
+ hiddenMs,
630
+ socketOpenAtReturn: this.socket?.readyState === SOCKET_OPEN,
631
+ sinceLastContactMs: this.lastContactAt === null ? null : Math.max(0, now - this.lastContactAt),
632
+ spell: SyncClient.delta(spell, this.snapshot()),
633
+ establishedAt: null,
634
+ catchupDone: false,
635
+ catchupOk: false,
636
+ eventsOnReturn: 0,
637
+ resyncOnReturn: 0,
638
+ timer: this.options.runtime.setTimeout(() => this.finishReturnWindow(), RETURN_REPORT_TIMEOUT_MS),
317
639
  };
318
640
  }
641
+ maybeFinishReturnWindow() {
642
+ const window = this.returnWindow;
643
+ if (window !== null && window.establishedAt !== null && window.catchupDone)
644
+ this.finishReturnWindow();
645
+ }
646
+ /** One INFO per hidden spell: what the user saw change on return. */
647
+ finishReturnWindow() {
648
+ const window = this.returnWindow;
649
+ if (window === null)
650
+ return;
651
+ this.returnWindow = null;
652
+ this.options.runtime.clearTimeout(window.timer);
653
+ this.options.logger.info('page returned to foreground', {
654
+ hiddenMs: window.hiddenMs,
655
+ socketOpenAtReturn: window.socketOpenAtReturn,
656
+ reconnectMs: window.establishedAt === null ? null : Math.max(0, window.establishedAt - window.visibleAt),
657
+ catchupOk: window.catchupOk,
658
+ eventsOnReturn: window.eventsOnReturn,
659
+ resyncOnReturn: window.resyncOnReturn,
660
+ sinceLastContactMs: window.sinceLastContactMs,
661
+ backgroundCatchups: window.spell.backgroundCatchups,
662
+ backgroundCatchupFailures: window.spell.backgroundCatchupFailures,
663
+ backgroundSocketAttempts: window.spell.backgroundSocketAttempts,
664
+ watchdogKills: window.spell.watchdogKills,
665
+ freezes: window.spell.freezes,
666
+ resumes: window.spell.resumes,
667
+ });
668
+ }
319
669
  connectNow() {
320
670
  if (this.reconnectTimer !== null)
321
671
  this.options.runtime.clearTimeout(this.reconnectTimer);
@@ -371,13 +721,23 @@ export class SyncClient {
371
721
  this.probeInFlight = false;
372
722
  }
373
723
  }
374
- startOutage(closeCode) {
724
+ startOutage(closeCode, wasClean = false) {
375
725
  if (this.outageStartedAt !== null)
376
726
  return;
377
727
  this.outageStartedAt = this.options.runtime.now();
378
728
  this.outageEscalated = false;
729
+ this.outageCloseCode = closeCode;
730
+ this.outageInfo = { closeCode, wasClean, visibleAtDrop: !this.isHidden(), counters: this.snapshot() };
731
+ this.armEscalation();
732
+ }
733
+ /** ERROR only after OUTAGE_ESCALATE_MS of visible, on-time outage; re-armed
734
+ * on each return so an outage that began hidden still escalates (S3). */
735
+ armEscalation() {
736
+ if (this.escalateTimer !== null)
737
+ this.options.runtime.clearTimeout(this.escalateTimer);
379
738
  this.escalateArmedAt = this.options.runtime.now();
380
739
  this.escalateTimer = this.options.runtime.setTimeout(() => {
740
+ this.escalateTimer = null;
381
741
  const late = this.options.runtime.now() - this.escalateArmedAt - OUTAGE_ESCALATE_MS;
382
742
  if (this.options.runtime.visibility() === 'hidden' || late > 5000)
383
743
  return;
@@ -385,7 +745,7 @@ export class SyncClient {
385
745
  this.outageEscalated = true;
386
746
  this.options.logger.error('sync outage ongoing', {
387
747
  durationMs: this.options.runtime.now() - this.outageStartedAt,
388
- closeCode,
748
+ closeCode: this.outageCloseCode,
389
749
  attempts: this.reconnectAttempts,
390
750
  });
391
751
  }
@@ -415,6 +775,9 @@ export class SyncClient {
415
775
  this.handleSocketCatchup(message);
416
776
  return;
417
777
  case 'reload':
778
+ if (this.connectReplyTimer !== null)
779
+ this.options.runtime.clearTimeout(this.connectReplyTimer);
780
+ this.connectReplyTimer = null;
418
781
  this.requestReload('server');
419
782
  return;
420
783
  case 'event':
@@ -426,6 +789,9 @@ export class SyncClient {
426
789
  }
427
790
  }
428
791
  handleSocketCatchup(message) {
792
+ if (this.connectReplyTimer !== null)
793
+ this.options.runtime.clearTimeout(this.connectReplyTimer);
794
+ this.connectReplyTimer = null;
429
795
  if (message.buildId)
430
796
  this.options.onBuildObserved?.(message.buildId);
431
797
  let complete = true;
@@ -446,8 +812,12 @@ export class SyncClient {
446
812
  return false;
447
813
  const currentSeq = this.options.store.currentSeq();
448
814
  if (event.seq <= currentSeq) {
449
- if (this.pending.has(event.eventGuid))
815
+ // A second echo of an already-confirmed edit (socket and HTTP catch-up
816
+ // both deliver it on return) must not upgrade the settlement to a full
817
+ // reconcile (review S4).
818
+ if (this.pending.has(event.eventGuid) && !this.confirmedPending.has(event.eventGuid)) {
450
819
  this.confirmPending(event.eventGuid, true);
820
+ }
451
821
  return true;
452
822
  }
453
823
  if (event.seq > currentSeq + 1) {
@@ -470,6 +840,8 @@ export class SyncClient {
470
840
  return false;
471
841
  }
472
842
  this.options.store.applyServerEvent(event);
843
+ if (this.returnWindow !== null && !this.pending.has(event.eventGuid))
844
+ this.returnWindow.eventsOnReturn++;
473
845
  if (this.stopBeforeStoreMutation())
474
846
  return false;
475
847
  this.options.store.setCurrentSeq(event.seq);
@@ -641,6 +1013,8 @@ export class SyncClient {
641
1013
  return true;
642
1014
  }
643
1015
  this.options.store.installBootstrap(bootstrap);
1016
+ if (this.returnWindow !== null)
1017
+ this.returnWindow.resyncOnReturn++;
644
1018
  for (const [eventGuid, entry] of this.pending) {
645
1019
  if (this.settledPending.has(eventGuid))
646
1020
  continue;
@@ -655,7 +1029,24 @@ export class SyncClient {
655
1029
  release();
656
1030
  }
657
1031
  }
1032
+ armFailureTimer(eventGuid) {
1033
+ this.clearFailureTimer(eventGuid);
1034
+ this.failureTimers.set(eventGuid, this.options.runtime.setTimeout(() => this.failUnconfirmed(eventGuid), FAILURE_TIMEOUT_MS));
1035
+ }
1036
+ /** On return: a verdict needs 60 s of visible time (review B2). */
1037
+ rearmFailureTimers() {
1038
+ for (const [eventGuid, entry] of this.pending) {
1039
+ if (entry.recovered || this.settledPending.has(eventGuid) || this.unresolvedFailures.has(eventGuid))
1040
+ continue;
1041
+ this.armFailureTimer(eventGuid);
1042
+ }
1043
+ }
658
1044
  failUnconfirmed(eventGuid) {
1045
+ this.failureTimers.delete(eventGuid);
1046
+ // Hidden: no verdict and no re-arm (that would wake the page every
1047
+ // minute); the return re-arms a full 60 s.
1048
+ if (this.isHidden())
1049
+ return;
659
1050
  const entry = this.pending.get(eventGuid);
660
1051
  if (!entry || entry.recovered)
661
1052
  return;
@@ -667,19 +1058,43 @@ export class SyncClient {
667
1058
  }
668
1059
  onSyncEstablished() {
669
1060
  if (this.outageStartedAt !== null) {
670
- const durationMs = this.options.runtime.now() - this.outageStartedAt;
1061
+ const durationMs = Math.max(0, this.options.runtime.now() - this.outageStartedAt);
1062
+ // Failures are only counted (and the ladder only runs) while visible,
1063
+ // so `attempts` is what the user could have waited through.
671
1064
  const attempts = this.reconnectAttempts;
1065
+ const info = this.outageInfo;
1066
+ const delta = info ? SyncClient.delta(info.counters, this.snapshot()) : null;
1067
+ const fields = {
1068
+ durationMs,
1069
+ attempts,
1070
+ closeCode: info?.closeCode,
1071
+ wasClean: info?.wasClean,
1072
+ visibleAtDrop: info?.visibleAtDrop,
1073
+ hiddenMs: delta?.hiddenMs,
1074
+ watchdogKills: delta?.watchdogKills,
1075
+ backgroundCatchups: delta?.backgroundCatchups,
1076
+ freezes: delta?.freezes,
1077
+ resumes: delta?.resumes,
1078
+ };
672
1079
  if (this.outageEscalated) {
673
- this.options.logger.info('sync outage recovered', { durationMs, attempts });
1080
+ this.options.logger.info('sync outage recovered', fields);
674
1081
  }
675
1082
  else if (attempts >= 2) {
676
- this.options.logger.warn('sync outage recovered', { durationMs, attempts });
1083
+ this.options.logger.warn('sync outage recovered', fields);
677
1084
  }
678
1085
  else if (attempts === 1) {
679
- this.options.logger.info('ws reconnected first try', { durationMs });
1086
+ this.options.logger.info('ws reconnected first try', fields);
1087
+ }
1088
+ else {
1089
+ this.options.logger.info('background sync outage recovered', fields);
680
1090
  }
681
1091
  this.outageStartedAt = null;
682
1092
  this.outageEscalated = false;
1093
+ this.outageInfo = null;
1094
+ }
1095
+ if (this.returnWindow !== null && this.returnWindow.establishedAt === null) {
1096
+ this.returnWindow.establishedAt = this.now();
1097
+ this.maybeFinishReturnWindow();
683
1098
  }
684
1099
  if (this.escalateTimer !== null)
685
1100
  this.options.runtime.clearTimeout(this.escalateTimer);
@@ -900,6 +1315,9 @@ export class SyncClient {
900
1315
  this.publish({ kind: 'expired', pending: this.pendingCount() });
901
1316
  }
902
1317
  clearConnectionTimers() {
1318
+ if (this.connectReplyTimer !== null)
1319
+ this.options.runtime.clearTimeout(this.connectReplyTimer);
1320
+ this.connectReplyTimer = null;
903
1321
  if (this.pingTimer !== null)
904
1322
  this.options.runtime.clearInterval(this.pingTimer);
905
1323
  if (this.watchdogTimer !== null)
@@ -922,6 +1340,10 @@ export class SyncClient {
922
1340
  this.options.runtime.clearTimeout(this.resyncBackoffTimer);
923
1341
  for (const timer of this.failureTimers.values())
924
1342
  this.options.runtime.clearTimeout(timer);
1343
+ this.cancelBackgroundTick();
1344
+ if (this.returnWindow !== null)
1345
+ this.options.runtime.clearTimeout(this.returnWindow.timer);
1346
+ this.returnWindow = null;
925
1347
  this.reconnectTimer = null;
926
1348
  this.countdownTimer = null;
927
1349
  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.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",