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