@steve31415/baselib 3.19.0 → 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;
@@ -116,6 +132,7 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
116
132
  private countdownTimer;
117
133
  private pingTimer;
118
134
  private watchdogTimer;
135
+ private socketCreatedAt;
119
136
  private rolloverTimer;
120
137
  private escalateTimer;
121
138
  private escalateArmedAt;
@@ -133,6 +150,23 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
133
150
  private reconciliationTail;
134
151
  private initialization;
135
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;
136
170
  constructor(options: SyncClientOptions<B, C, S, Summary>);
137
171
  init(): Promise<void>;
138
172
  private initialize;
@@ -153,12 +187,41 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
153
187
  state(): SyncClientState;
154
188
  subscribe(listener: (state: SyncClientState) => void): () => void;
155
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;
156
198
  private wakeConnection;
199
+ /** Drops the current socket, if any, without treating it as an outage, and
200
+ * connects at once. */
201
+ private replaceSocket;
157
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;
158
218
  private connectNow;
159
219
  private scheduleReconnect;
160
220
  private probeSession;
161
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;
162
225
  private handleMessage;
163
226
  private handleSocketCatchup;
164
227
  /** Returns false when a forward gap prevented application. */
@@ -170,6 +233,9 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
170
233
  private finishConfirmation;
171
234
  private finishRejection;
172
235
  private reconcileStore;
236
+ private armFailureTimer;
237
+ /** On return: a verdict needs 60 s of visible time (review B2). */
238
+ private rearmFailureTimers;
173
239
  private failUnconfirmed;
174
240
  private onSyncEstablished;
175
241
  private requestReload;
@@ -15,6 +15,13 @@ 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;
24
+ const SOCKET_CONNECTING = 0;
18
25
  const SOCKET_OPEN = 1;
19
26
  class LifecycleAbort {
20
27
  reason;
@@ -45,6 +52,7 @@ export class SyncClient {
45
52
  countdownTimer = null;
46
53
  pingTimer = null;
47
54
  watchdogTimer = null;
55
+ socketCreatedAt = 0;
48
56
  rolloverTimer = null;
49
57
  escalateTimer = null;
50
58
  escalateArmedAt = 0;
@@ -62,6 +70,32 @@ export class SyncClient {
62
70
  reconciliationTail = Promise.resolve();
63
71
  initialization = null;
64
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
+ };
65
99
  constructor(options) {
66
100
  this.options = options;
67
101
  }
@@ -176,7 +210,7 @@ export class SyncClient {
176
210
  const entry = { ...persisted, status: 'pending' };
177
211
  this.pending.set(event.eventGuid, entry);
178
212
  this.options.store.applyOptimistic(event);
179
- this.failureTimers.set(event.eventGuid, this.options.runtime.setTimeout(() => this.failUnconfirmed(event.eventGuid), FAILURE_TIMEOUT_MS));
213
+ this.armFailureTimer(event.eventGuid);
180
214
  this.trySend(entry);
181
215
  this.updatePendingState();
182
216
  this.options.onSafetyChange?.();
@@ -207,14 +241,7 @@ export class SyncClient {
207
241
  if (this.halted || this.disposed)
208
242
  return;
209
243
  this.options.logger.info('sync reconnect for traffic build');
210
- const previous = this.socket;
211
- this.socket = null;
212
- this.clearConnectionTimers();
213
- if (previous) {
214
- previous.close();
215
- this.detachSocket(previous);
216
- }
217
- this.connectNow();
244
+ this.replaceSocket();
218
245
  }
219
246
  state() {
220
247
  return this.currentState;
@@ -224,19 +251,136 @@ export class SyncClient {
224
251
  return () => this.subscribers.delete(listener);
225
252
  }
226
253
  installRuntimeListeners() {
227
- this.removeListeners.push(this.options.runtime.onVisibilityChange(() => {
228
- if (this.options.runtime.visibility() === 'visible')
229
- this.wakeConnection();
230
- }), 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();
231
344
  }
232
345
  wakeConnection() {
233
346
  if (this.halted || this.disposed)
234
347
  return;
235
- if (this.socket !== null) {
236
- if (this.socket.readyState === SOCKET_OPEN)
237
- this.sendConnect();
348
+ const socket = this.socket;
349
+ if (socket?.readyState === SOCKET_OPEN) {
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);
360
+ return;
361
+ }
362
+ // A handshake still inside its watchdog window may just be slow: leave
363
+ // it. One past that window began before the page was frozen (a
364
+ // backgrounded phone tab) and its watchdog never ran; a socket already
365
+ // closing is gone too. Waiting on either routes the returning user
366
+ // through the watchdog's close and a full backoff step (16-30 s of
367
+ // "Reconnecting…" observed on reader, 2026-09-24).
368
+ if (socket?.readyState === SOCKET_CONNECTING &&
369
+ this.options.runtime.now() - this.socketCreatedAt < CONNECT_WATCHDOG_MS) {
238
370
  return;
239
371
  }
372
+ this.replaceSocket();
373
+ }
374
+ /** Drops the current socket, if any, without treating it as an outage, and
375
+ * connects at once. */
376
+ replaceSocket() {
377
+ const previous = this.socket;
378
+ this.socket = null;
379
+ this.clearConnectionTimers();
380
+ if (previous) {
381
+ previous.close();
382
+ this.detachSocket(previous);
383
+ }
240
384
  this.connectNow();
241
385
  }
242
386
  connect() {
@@ -252,13 +396,23 @@ export class SyncClient {
252
396
  catch (error) {
253
397
  this.options.logger.warn('socket creation failed', { error });
254
398
  this.startOutage(1006);
255
- this.scheduleReconnect();
399
+ if (this.isHidden())
400
+ this.enterDormant();
401
+ else
402
+ this.scheduleReconnect();
256
403
  return;
257
404
  }
258
405
  this.socket = socket;
406
+ this.socketCreatedAt = this.options.runtime.now();
407
+ this.socketCreatedHidden = this.isHidden();
408
+ this.socketOpenedAt = null;
409
+ if (this.socketCreatedHidden)
410
+ this.counters.backgroundSocketAttempts++;
259
411
  this.watchdogTimer = this.options.runtime.setTimeout(() => {
260
- if (socket.readyState !== SOCKET_OPEN)
412
+ if (socket.readyState !== SOCKET_OPEN) {
413
+ this.counters.watchdogKills++;
261
414
  socket.close();
415
+ }
262
416
  }, CONNECT_WATCHDOG_MS);
263
417
  socket.onopen = () => {
264
418
  if (this.socket !== socket) {
@@ -268,16 +422,21 @@ export class SyncClient {
268
422
  if (this.watchdogTimer !== null)
269
423
  this.options.runtime.clearTimeout(this.watchdogTimer);
270
424
  this.watchdogTimer = null;
425
+ this.socketOpenedAt = this.now();
426
+ this.cancelBackgroundTick();
271
427
  this.sendConnect();
272
428
  this.pingTimer = this.options.runtime.setInterval(() => this.sendRaw({ type: 'ping' }), PING_INTERVAL_MS);
273
429
  this.rolloverTimer = this.options.runtime.setTimeout(() => {
274
430
  this.options.logger.info('ws rollover', {});
431
+ this.plannedClose = socket;
275
432
  socket.close();
276
433
  }, this.options.reconnectBeforeMs ?? DEFAULT_RECONNECT_BEFORE_MS);
277
434
  };
278
435
  socket.onmessage = (event) => {
279
- if (this.socket === socket)
280
- this.handleMessage(String(event.data));
436
+ if (this.socket !== socket)
437
+ return;
438
+ this.lastContactAt = this.now();
439
+ this.handleMessage(String(event.data));
281
440
  };
282
441
  socket.onerror = () => {
283
442
  // Browsers expose no useful detail; the close event owns recovery.
@@ -294,10 +453,219 @@ export class SyncClient {
294
453
  this.halt();
295
454
  return;
296
455
  }
297
- this.startOutage(event.code);
298
- 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();
299
473
  };
300
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),
639
+ };
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
+ }
301
669
  connectNow() {
302
670
  if (this.reconnectTimer !== null)
303
671
  this.options.runtime.clearTimeout(this.reconnectTimer);
@@ -353,13 +721,23 @@ export class SyncClient {
353
721
  this.probeInFlight = false;
354
722
  }
355
723
  }
356
- startOutage(closeCode) {
724
+ startOutage(closeCode, wasClean = false) {
357
725
  if (this.outageStartedAt !== null)
358
726
  return;
359
727
  this.outageStartedAt = this.options.runtime.now();
360
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);
361
738
  this.escalateArmedAt = this.options.runtime.now();
362
739
  this.escalateTimer = this.options.runtime.setTimeout(() => {
740
+ this.escalateTimer = null;
363
741
  const late = this.options.runtime.now() - this.escalateArmedAt - OUTAGE_ESCALATE_MS;
364
742
  if (this.options.runtime.visibility() === 'hidden' || late > 5000)
365
743
  return;
@@ -367,7 +745,7 @@ export class SyncClient {
367
745
  this.outageEscalated = true;
368
746
  this.options.logger.error('sync outage ongoing', {
369
747
  durationMs: this.options.runtime.now() - this.outageStartedAt,
370
- closeCode,
748
+ closeCode: this.outageCloseCode,
371
749
  attempts: this.reconnectAttempts,
372
750
  });
373
751
  }
@@ -397,6 +775,9 @@ export class SyncClient {
397
775
  this.handleSocketCatchup(message);
398
776
  return;
399
777
  case 'reload':
778
+ if (this.connectReplyTimer !== null)
779
+ this.options.runtime.clearTimeout(this.connectReplyTimer);
780
+ this.connectReplyTimer = null;
400
781
  this.requestReload('server');
401
782
  return;
402
783
  case 'event':
@@ -408,6 +789,9 @@ export class SyncClient {
408
789
  }
409
790
  }
410
791
  handleSocketCatchup(message) {
792
+ if (this.connectReplyTimer !== null)
793
+ this.options.runtime.clearTimeout(this.connectReplyTimer);
794
+ this.connectReplyTimer = null;
411
795
  if (message.buildId)
412
796
  this.options.onBuildObserved?.(message.buildId);
413
797
  let complete = true;
@@ -428,8 +812,12 @@ export class SyncClient {
428
812
  return false;
429
813
  const currentSeq = this.options.store.currentSeq();
430
814
  if (event.seq <= currentSeq) {
431
- 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)) {
432
819
  this.confirmPending(event.eventGuid, true);
820
+ }
433
821
  return true;
434
822
  }
435
823
  if (event.seq > currentSeq + 1) {
@@ -452,6 +840,8 @@ export class SyncClient {
452
840
  return false;
453
841
  }
454
842
  this.options.store.applyServerEvent(event);
843
+ if (this.returnWindow !== null && !this.pending.has(event.eventGuid))
844
+ this.returnWindow.eventsOnReturn++;
455
845
  if (this.stopBeforeStoreMutation())
456
846
  return false;
457
847
  this.options.store.setCurrentSeq(event.seq);
@@ -623,6 +1013,8 @@ export class SyncClient {
623
1013
  return true;
624
1014
  }
625
1015
  this.options.store.installBootstrap(bootstrap);
1016
+ if (this.returnWindow !== null)
1017
+ this.returnWindow.resyncOnReturn++;
626
1018
  for (const [eventGuid, entry] of this.pending) {
627
1019
  if (this.settledPending.has(eventGuid))
628
1020
  continue;
@@ -637,7 +1029,24 @@ export class SyncClient {
637
1029
  release();
638
1030
  }
639
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
+ }
640
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;
641
1050
  const entry = this.pending.get(eventGuid);
642
1051
  if (!entry || entry.recovered)
643
1052
  return;
@@ -649,19 +1058,43 @@ export class SyncClient {
649
1058
  }
650
1059
  onSyncEstablished() {
651
1060
  if (this.outageStartedAt !== null) {
652
- 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.
653
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
+ };
654
1079
  if (this.outageEscalated) {
655
- this.options.logger.info('sync outage recovered', { durationMs, attempts });
1080
+ this.options.logger.info('sync outage recovered', fields);
656
1081
  }
657
1082
  else if (attempts >= 2) {
658
- this.options.logger.warn('sync outage recovered', { durationMs, attempts });
1083
+ this.options.logger.warn('sync outage recovered', fields);
659
1084
  }
660
1085
  else if (attempts === 1) {
661
- 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);
662
1090
  }
663
1091
  this.outageStartedAt = null;
664
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();
665
1098
  }
666
1099
  if (this.escalateTimer !== null)
667
1100
  this.options.runtime.clearTimeout(this.escalateTimer);
@@ -882,6 +1315,9 @@ export class SyncClient {
882
1315
  this.publish({ kind: 'expired', pending: this.pendingCount() });
883
1316
  }
884
1317
  clearConnectionTimers() {
1318
+ if (this.connectReplyTimer !== null)
1319
+ this.options.runtime.clearTimeout(this.connectReplyTimer);
1320
+ this.connectReplyTimer = null;
885
1321
  if (this.pingTimer !== null)
886
1322
  this.options.runtime.clearInterval(this.pingTimer);
887
1323
  if (this.watchdogTimer !== null)
@@ -904,6 +1340,10 @@ export class SyncClient {
904
1340
  this.options.runtime.clearTimeout(this.resyncBackoffTimer);
905
1341
  for (const timer of this.failureTimers.values())
906
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;
907
1347
  this.reconnectTimer = null;
908
1348
  this.countdownTimer = null;
909
1349
  this.escalateTimer = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.19.0",
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",