@steve31415/baselib 3.31.0 → 3.32.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.
- package/dist/sync-browser/client.d.ts +17 -0
- package/dist/sync-browser/client.js +70 -13
- package/package.json +1 -1
|
@@ -32,6 +32,9 @@ export interface CatchupResult<S extends EventBody> {
|
|
|
32
32
|
}
|
|
33
33
|
export interface SyncBrowserRuntime {
|
|
34
34
|
now(): number;
|
|
35
|
+
/** A monotonic clock (`performance.now()`), used only to tell a page that
|
|
36
|
+
* was not running from one that was. Defaults to `performance.now()`. */
|
|
37
|
+
monotonicNow?(): number;
|
|
35
38
|
randomGuid(): string;
|
|
36
39
|
createSocket(url: string): WebSocket;
|
|
37
40
|
sendBeacon(url: string, body: Blob): boolean;
|
|
@@ -171,6 +174,9 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
171
174
|
private lastContactAt;
|
|
172
175
|
private spellCounters;
|
|
173
176
|
private returnWindow;
|
|
177
|
+
private clockOffset;
|
|
178
|
+
private stallCoveredUntil;
|
|
179
|
+
private visibleSinceMono;
|
|
174
180
|
private readonly counters;
|
|
175
181
|
constructor(options: SyncClientOptions<B, C, S, Summary>);
|
|
176
182
|
init(): Promise<void>;
|
|
@@ -192,6 +198,17 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
192
198
|
state(): SyncClientState;
|
|
193
199
|
subscribe(listener: (state: SyncClientState) => void): () => void;
|
|
194
200
|
private installRuntimeListeners;
|
|
201
|
+
private monoNow;
|
|
202
|
+
private rebaselineClocks;
|
|
203
|
+
/** Runs first in every client timer. A device asleep stops the monotonic
|
|
204
|
+
* clock and the timers with it, so the wall clock's lead over it grows by
|
|
205
|
+
* the sleep; a frozen page leaves both clocks running and its timers run
|
|
206
|
+
* late by the monotonic one. Overlapping late timers count once. Only time
|
|
207
|
+
* the page counted as visible is counted (hidden pages are throttled by
|
|
208
|
+
* design and their time is `hiddenMs`). */
|
|
209
|
+
private observeStall;
|
|
210
|
+
private setTimer;
|
|
211
|
+
private setRepeating;
|
|
195
212
|
private isHidden;
|
|
196
213
|
private now;
|
|
197
214
|
/** Counters with the running hidden spell's time folded in. */
|
|
@@ -35,6 +35,9 @@ const RETURN_QUICK_RETRY_MS = 250;
|
|
|
35
35
|
* resolves (notes, 2026-10-01: four minutes). Fast failures (offline) are
|
|
36
36
|
* not logged. */
|
|
37
37
|
const SLOW_HANDSHAKE_MS = 3_000;
|
|
38
|
+
/** A timer that runs at least this late, or a wall-clock jump of at least
|
|
39
|
+
* this much past the monotonic clock, counts as time the page was stalled. */
|
|
40
|
+
const STALL_MIN_MS = 1_000;
|
|
38
41
|
const SOCKET_CONNECTING = 0;
|
|
39
42
|
const SOCKET_OPEN = 1;
|
|
40
43
|
class LifecycleAbort {
|
|
@@ -106,8 +109,13 @@ export class SyncClient {
|
|
|
106
109
|
lastContactAt = null;
|
|
107
110
|
spellCounters = null;
|
|
108
111
|
returnWindow = null;
|
|
112
|
+
// Stall accounting (Counters.stalledMs).
|
|
113
|
+
clockOffset = null;
|
|
114
|
+
stallCoveredUntil = 0;
|
|
115
|
+
visibleSinceMono = 0;
|
|
109
116
|
counters = {
|
|
110
117
|
hiddenMs: 0,
|
|
118
|
+
stalledMs: 0,
|
|
111
119
|
freezes: 0,
|
|
112
120
|
resumes: 0,
|
|
113
121
|
watchdogKills: 0,
|
|
@@ -277,6 +285,7 @@ export class SyncClient {
|
|
|
277
285
|
}
|
|
278
286
|
installRuntimeListeners() {
|
|
279
287
|
const runtime = this.options.runtime;
|
|
288
|
+
this.rebaselineClocks();
|
|
280
289
|
// Loaded hidden (a background tab): the spell starts now; the first
|
|
281
290
|
// connect still runs, and its failure goes dormant rather than retrying.
|
|
282
291
|
if (runtime.visibility() === 'hidden') {
|
|
@@ -296,6 +305,48 @@ export class SyncClient {
|
|
|
296
305
|
if (onResume)
|
|
297
306
|
this.removeListeners.push(onResume);
|
|
298
307
|
}
|
|
308
|
+
monoNow() {
|
|
309
|
+
return this.options.runtime.monotonicNow?.() ?? performance.now();
|
|
310
|
+
}
|
|
311
|
+
rebaselineClocks() {
|
|
312
|
+
this.clockOffset = this.now() - this.monoNow();
|
|
313
|
+
}
|
|
314
|
+
/** Runs first in every client timer. A device asleep stops the monotonic
|
|
315
|
+
* clock and the timers with it, so the wall clock's lead over it grows by
|
|
316
|
+
* the sleep; a frozen page leaves both clocks running and its timers run
|
|
317
|
+
* late by the monotonic one. Overlapping late timers count once. Only time
|
|
318
|
+
* the page counted as visible is counted (hidden pages are throttled by
|
|
319
|
+
* design and their time is `hiddenMs`). */
|
|
320
|
+
observeStall(dueMono) {
|
|
321
|
+
const mono = this.monoNow();
|
|
322
|
+
const offset = this.now() - mono;
|
|
323
|
+
const visible = !this.isHidden();
|
|
324
|
+
if (visible && this.clockOffset !== null && offset - this.clockOffset >= STALL_MIN_MS) {
|
|
325
|
+
this.counters.stalledMs += offset - this.clockOffset;
|
|
326
|
+
}
|
|
327
|
+
this.clockOffset = offset;
|
|
328
|
+
if (visible && mono - dueMono >= STALL_MIN_MS) {
|
|
329
|
+
const from = Math.max(dueMono, this.stallCoveredUntil, this.visibleSinceMono);
|
|
330
|
+
if (mono > from)
|
|
331
|
+
this.counters.stalledMs += mono - from;
|
|
332
|
+
this.stallCoveredUntil = mono;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
setTimer(callback, delayMs) {
|
|
336
|
+
const dueMono = this.monoNow() + delayMs;
|
|
337
|
+
return this.options.runtime.setTimeout(() => {
|
|
338
|
+
this.observeStall(dueMono);
|
|
339
|
+
callback();
|
|
340
|
+
}, delayMs);
|
|
341
|
+
}
|
|
342
|
+
setRepeating(callback, periodMs) {
|
|
343
|
+
let dueMono = this.monoNow() + periodMs;
|
|
344
|
+
return this.options.runtime.setInterval(() => {
|
|
345
|
+
this.observeStall(dueMono);
|
|
346
|
+
dueMono = this.monoNow() + periodMs;
|
|
347
|
+
callback();
|
|
348
|
+
}, periodMs);
|
|
349
|
+
}
|
|
299
350
|
isHidden() {
|
|
300
351
|
return this.hiddenSince !== null;
|
|
301
352
|
}
|
|
@@ -310,6 +361,7 @@ export class SyncClient {
|
|
|
310
361
|
static delta(since, now) {
|
|
311
362
|
return {
|
|
312
363
|
hiddenMs: Math.max(0, now.hiddenMs - since.hiddenMs),
|
|
364
|
+
stalledMs: now.stalledMs - since.stalledMs,
|
|
313
365
|
freezes: now.freezes - since.freezes,
|
|
314
366
|
resumes: now.resumes - since.resumes,
|
|
315
367
|
watchdogKills: now.watchdogKills - since.watchdogKills,
|
|
@@ -323,6 +375,8 @@ export class SyncClient {
|
|
|
323
375
|
if (this.halted || this.disposed || this.hiddenSince !== null)
|
|
324
376
|
return;
|
|
325
377
|
this.finishReturnWindow();
|
|
378
|
+
// A device that slept and then hid the page: the sleep counts as hidden.
|
|
379
|
+
this.rebaselineClocks();
|
|
326
380
|
this.hiddenSince = this.now();
|
|
327
381
|
this.spellCounters = this.snapshot();
|
|
328
382
|
this.hiddenSocketFailed = false;
|
|
@@ -339,6 +393,8 @@ export class SyncClient {
|
|
|
339
393
|
return;
|
|
340
394
|
const hiddenSince = this.hiddenSince;
|
|
341
395
|
if (hiddenSince !== null) {
|
|
396
|
+
this.rebaselineClocks();
|
|
397
|
+
this.visibleSinceMono = this.monoNow();
|
|
342
398
|
const hiddenMs = Math.max(0, this.now() - hiddenSince);
|
|
343
399
|
const spell = this.spellCounters ?? this.snapshot();
|
|
344
400
|
this.counters.hiddenMs += hiddenMs;
|
|
@@ -382,7 +438,7 @@ export class SyncClient {
|
|
|
382
438
|
// replaces it.
|
|
383
439
|
if (this.connectReplyTimer !== null)
|
|
384
440
|
this.options.runtime.clearTimeout(this.connectReplyTimer);
|
|
385
|
-
this.connectReplyTimer = this.
|
|
441
|
+
this.connectReplyTimer = this.setTimer(() => {
|
|
386
442
|
this.connectReplyTimer = null;
|
|
387
443
|
if (this.socket === socket && !this.isHidden())
|
|
388
444
|
this.replaceSocket();
|
|
@@ -452,7 +508,7 @@ export class SyncClient {
|
|
|
452
508
|
if (this.socketCreatedHidden)
|
|
453
509
|
this.counters.backgroundSocketAttempts++;
|
|
454
510
|
let killedByWatchdog = false;
|
|
455
|
-
this.watchdogTimer = this.
|
|
511
|
+
this.watchdogTimer = this.setTimer(() => {
|
|
456
512
|
if (socket.readyState !== SOCKET_OPEN) {
|
|
457
513
|
this.counters.watchdogKills++;
|
|
458
514
|
killedByWatchdog = true;
|
|
@@ -479,8 +535,8 @@ export class SyncClient {
|
|
|
479
535
|
}
|
|
480
536
|
this.cancelBackgroundTick();
|
|
481
537
|
this.sendConnect();
|
|
482
|
-
this.pingTimer = this.
|
|
483
|
-
this.rolloverTimer = this.
|
|
538
|
+
this.pingTimer = this.setRepeating(() => this.sendRaw({ type: 'ping' }), PING_INTERVAL_MS);
|
|
539
|
+
this.rolloverTimer = this.setTimer(() => {
|
|
484
540
|
this.options.logger.info('ws rollover', {});
|
|
485
541
|
this.plannedClose = socket;
|
|
486
542
|
socket.close();
|
|
@@ -556,7 +612,7 @@ export class SyncClient {
|
|
|
556
612
|
return;
|
|
557
613
|
const interval = this.options.backgroundCatchupMs ?? DEFAULT_BACKGROUND_CATCHUP_MS;
|
|
558
614
|
const due = this.lastTickAt === null ? 0 : Math.max(0, this.lastTickAt + interval - this.now());
|
|
559
|
-
this.backgroundTickTimer = this.
|
|
615
|
+
this.backgroundTickTimer = this.setTimer(() => {
|
|
560
616
|
this.backgroundTickTimer = null;
|
|
561
617
|
void this.backgroundTick();
|
|
562
618
|
}, Math.min(due, interval));
|
|
@@ -633,7 +689,7 @@ export class SyncClient {
|
|
|
633
689
|
const slot = { kind, controller };
|
|
634
690
|
this.catchupSlot = slot;
|
|
635
691
|
this.abortControllers.add(controller);
|
|
636
|
-
const timeout = this.
|
|
692
|
+
const timeout = this.setTimer(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
|
|
637
693
|
let ok = false;
|
|
638
694
|
try {
|
|
639
695
|
const result = await this.options.fetchCatchup(this.options.store.currentSeq(), controller.signal);
|
|
@@ -678,7 +734,7 @@ export class SyncClient {
|
|
|
678
734
|
async withTimeout(operation) {
|
|
679
735
|
const controller = new AbortController();
|
|
680
736
|
this.abortControllers.add(controller);
|
|
681
|
-
const timeout = this.
|
|
737
|
+
const timeout = this.setTimer(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
|
|
682
738
|
try {
|
|
683
739
|
return await operation(controller.signal);
|
|
684
740
|
}
|
|
@@ -704,7 +760,7 @@ export class SyncClient {
|
|
|
704
760
|
failures: 0,
|
|
705
761
|
firstFailure: null,
|
|
706
762
|
quickRetryUsed: false,
|
|
707
|
-
timer: this.
|
|
763
|
+
timer: this.setTimer(() => this.finishReturnWindow(), RETURN_REPORT_TIMEOUT_MS),
|
|
708
764
|
};
|
|
709
765
|
}
|
|
710
766
|
maybeFinishReturnWindow() {
|
|
@@ -785,13 +841,13 @@ export class SyncClient {
|
|
|
785
841
|
}
|
|
786
842
|
let remaining = delay;
|
|
787
843
|
this.publish({ kind: 'reconnecting', pending: this.pendingCount(), inSeconds: remaining });
|
|
788
|
-
this.countdownTimer = this.
|
|
844
|
+
this.countdownTimer = this.setRepeating(() => {
|
|
789
845
|
remaining--;
|
|
790
846
|
if (remaining > 0) {
|
|
791
847
|
this.publish({ kind: 'reconnecting', pending: this.pendingCount(), inSeconds: remaining });
|
|
792
848
|
}
|
|
793
849
|
}, 1000);
|
|
794
|
-
this.reconnectTimer = this.
|
|
850
|
+
this.reconnectTimer = this.setTimer(() => {
|
|
795
851
|
if (this.countdownTimer !== null)
|
|
796
852
|
this.options.runtime.clearInterval(this.countdownTimer);
|
|
797
853
|
this.countdownTimer = null;
|
|
@@ -858,7 +914,7 @@ export class SyncClient {
|
|
|
858
914
|
if (this.escalateTimer !== null)
|
|
859
915
|
this.options.runtime.clearTimeout(this.escalateTimer);
|
|
860
916
|
this.escalateArmedAt = this.options.runtime.now();
|
|
861
|
-
this.escalateTimer = this.
|
|
917
|
+
this.escalateTimer = this.setTimer(() => {
|
|
862
918
|
this.escalateTimer = null;
|
|
863
919
|
const late = this.options.runtime.now() - this.escalateArmedAt - OUTAGE_ESCALATE_MS;
|
|
864
920
|
if (this.options.runtime.visibility() === 'hidden' || late > 5000)
|
|
@@ -1153,7 +1209,7 @@ export class SyncClient {
|
|
|
1153
1209
|
}
|
|
1154
1210
|
armFailureTimer(eventGuid) {
|
|
1155
1211
|
this.clearFailureTimer(eventGuid);
|
|
1156
|
-
this.failureTimers.set(eventGuid, this.
|
|
1212
|
+
this.failureTimers.set(eventGuid, this.setTimer(() => this.failUnconfirmed(eventGuid), FAILURE_TIMEOUT_MS));
|
|
1157
1213
|
}
|
|
1158
1214
|
/** On return: a verdict needs 60 s of visible time (review B2). */
|
|
1159
1215
|
rearmFailureTimers() {
|
|
@@ -1195,6 +1251,7 @@ export class SyncClient {
|
|
|
1195
1251
|
wasClean: info?.wasClean,
|
|
1196
1252
|
visibleAtDrop: info?.visibleAtDrop,
|
|
1197
1253
|
hiddenMs: delta?.hiddenMs,
|
|
1254
|
+
stalledMs: delta?.stalledMs,
|
|
1198
1255
|
watchdogKills: delta?.watchdogKills,
|
|
1199
1256
|
backgroundCatchups: delta?.backgroundCatchups,
|
|
1200
1257
|
httpPostedEvents: delta?.httpPostedEvents,
|
|
@@ -1332,7 +1389,7 @@ export class SyncClient {
|
|
|
1332
1389
|
return;
|
|
1333
1390
|
// A hidden tab's timer throttling may stretch this well past the nominal
|
|
1334
1391
|
// delay; that only postpones recovery, it never breaks it.
|
|
1335
|
-
this.resyncBackoffTimer = this.
|
|
1392
|
+
this.resyncBackoffTimer = this.setTimer(() => {
|
|
1336
1393
|
this.resyncBackoffTimer = null;
|
|
1337
1394
|
void this.resyncFromServer('retry after resync backoff');
|
|
1338
1395
|
}, RESYNC_RETRY_MS);
|
package/package.json
CHANGED