@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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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.
|
|
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
|
|
298
|
-
|
|
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.
|
|
316
|
-
this.
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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',
|
|
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