@steve31415/baselib 3.19.1 → 3.20.1
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;
|
|
@@ -111,7 +127,11 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
111
127
|
private readonly abortControllers;
|
|
112
128
|
private readonly removeListeners;
|
|
113
129
|
private currentState;
|
|
130
|
+
/** Failed attempts in the current visible spell: drives the ladder and the
|
|
131
|
+
* outage log's `attempts` (what the user could have waited through). */
|
|
114
132
|
private reconnectAttempts;
|
|
133
|
+
/** Failed visible attempts across the whole outage (every spell). */
|
|
134
|
+
private totalAttempts;
|
|
115
135
|
private reconnectTimer;
|
|
116
136
|
private countdownTimer;
|
|
117
137
|
private pingTimer;
|
|
@@ -134,6 +154,23 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
134
154
|
private reconciliationTail;
|
|
135
155
|
private initialization;
|
|
136
156
|
private disposed;
|
|
157
|
+
private hiddenSince;
|
|
158
|
+
private hiddenSocketFailed;
|
|
159
|
+
private beaconedWhileDormant;
|
|
160
|
+
private backgroundTickTimer;
|
|
161
|
+
private lastTickAt;
|
|
162
|
+
private catchupSlot;
|
|
163
|
+
private readonly httpApplied;
|
|
164
|
+
private connectReplyTimer;
|
|
165
|
+
private socketCreatedHidden;
|
|
166
|
+
private socketOpenedAt;
|
|
167
|
+
private plannedClose;
|
|
168
|
+
private outageCloseCode;
|
|
169
|
+
private outageInfo;
|
|
170
|
+
private lastContactAt;
|
|
171
|
+
private spellCounters;
|
|
172
|
+
private returnWindow;
|
|
173
|
+
private readonly counters;
|
|
137
174
|
constructor(options: SyncClientOptions<B, C, S, Summary>);
|
|
138
175
|
init(): Promise<void>;
|
|
139
176
|
private initialize;
|
|
@@ -154,15 +191,41 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
154
191
|
state(): SyncClientState;
|
|
155
192
|
subscribe(listener: (state: SyncClientState) => void): () => void;
|
|
156
193
|
private installRuntimeListeners;
|
|
194
|
+
private isHidden;
|
|
195
|
+
private now;
|
|
196
|
+
/** Counters with the running hidden spell's time folded in. */
|
|
197
|
+
private snapshot;
|
|
198
|
+
private static delta;
|
|
199
|
+
private onBecameHidden;
|
|
200
|
+
private onBecameVisible;
|
|
201
|
+
private onOnline;
|
|
157
202
|
private wakeConnection;
|
|
158
203
|
/** Drops the current socket, if any, without treating it as an outage, and
|
|
159
204
|
* connects at once. */
|
|
160
205
|
private replaceSocket;
|
|
161
206
|
private connect;
|
|
207
|
+
/** Hidden and disconnected: no WebSocket retries; HTTP catch-ups on a slow
|
|
208
|
+
* cadence instead (docs/SYNC-BACKGROUND-PLAN.md). */
|
|
209
|
+
private enterDormant;
|
|
210
|
+
private cancelBackgroundTick;
|
|
211
|
+
private backgroundTick;
|
|
212
|
+
private unsettledPending;
|
|
213
|
+
private postPendingOverHttp;
|
|
214
|
+
/** The background/return catch-up (review S5): one slot, a return aborts
|
|
215
|
+
* a background fetch in flight, both give up after 15 s. */
|
|
216
|
+
private runCatchup;
|
|
217
|
+
private withTimeout;
|
|
218
|
+
private openReturnWindow;
|
|
219
|
+
private maybeFinishReturnWindow;
|
|
220
|
+
/** One INFO per hidden spell: what the user saw change on return. */
|
|
221
|
+
private finishReturnWindow;
|
|
162
222
|
private connectNow;
|
|
163
223
|
private scheduleReconnect;
|
|
164
224
|
private probeSession;
|
|
165
225
|
private startOutage;
|
|
226
|
+
/** ERROR only after OUTAGE_ESCALATE_MS of visible, on-time outage; re-armed
|
|
227
|
+
* on each return so an outage that began hidden still escalates (S3). */
|
|
228
|
+
private armEscalation;
|
|
166
229
|
private handleMessage;
|
|
167
230
|
private handleSocketCatchup;
|
|
168
231
|
/** Returns false when a forward gap prevented application. */
|
|
@@ -174,6 +237,9 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
174
237
|
private finishConfirmation;
|
|
175
238
|
private finishRejection;
|
|
176
239
|
private reconcileStore;
|
|
240
|
+
private armFailureTimer;
|
|
241
|
+
/** On return: a verdict needs 60 s of visible time (review B2). */
|
|
242
|
+
private rearmFailureTimers;
|
|
177
243
|
private failUnconfirmed;
|
|
178
244
|
private onSyncEstablished;
|
|
179
245
|
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 {
|
|
@@ -41,7 +47,11 @@ export class SyncClient {
|
|
|
41
47
|
abortControllers = new Set();
|
|
42
48
|
removeListeners = [];
|
|
43
49
|
currentState = { kind: 'starting', pending: 0 };
|
|
50
|
+
/** Failed attempts in the current visible spell: drives the ladder and the
|
|
51
|
+
* outage log's `attempts` (what the user could have waited through). */
|
|
44
52
|
reconnectAttempts = 0;
|
|
53
|
+
/** Failed visible attempts across the whole outage (every spell). */
|
|
54
|
+
totalAttempts = 0;
|
|
45
55
|
reconnectTimer = null;
|
|
46
56
|
countdownTimer = null;
|
|
47
57
|
pingTimer = null;
|
|
@@ -64,6 +74,32 @@ export class SyncClient {
|
|
|
64
74
|
reconciliationTail = Promise.resolve();
|
|
65
75
|
initialization = null;
|
|
66
76
|
disposed = false;
|
|
77
|
+
// Hidden-page state (docs/SYNC-BACKGROUND-PLAN.md).
|
|
78
|
+
hiddenSince = null;
|
|
79
|
+
hiddenSocketFailed = false;
|
|
80
|
+
beaconedWhileDormant = false;
|
|
81
|
+
backgroundTickTimer = null;
|
|
82
|
+
lastTickAt = null;
|
|
83
|
+
catchupSlot = null;
|
|
84
|
+
httpApplied = new Set();
|
|
85
|
+
connectReplyTimer = null;
|
|
86
|
+
socketCreatedHidden = false;
|
|
87
|
+
socketOpenedAt = null;
|
|
88
|
+
plannedClose = null;
|
|
89
|
+
outageCloseCode = 1006;
|
|
90
|
+
outageInfo = null;
|
|
91
|
+
lastContactAt = null;
|
|
92
|
+
spellCounters = null;
|
|
93
|
+
returnWindow = null;
|
|
94
|
+
counters = {
|
|
95
|
+
hiddenMs: 0,
|
|
96
|
+
freezes: 0,
|
|
97
|
+
resumes: 0,
|
|
98
|
+
watchdogKills: 0,
|
|
99
|
+
backgroundCatchups: 0,
|
|
100
|
+
backgroundCatchupFailures: 0,
|
|
101
|
+
backgroundSocketAttempts: 0,
|
|
102
|
+
};
|
|
67
103
|
constructor(options) {
|
|
68
104
|
this.options = options;
|
|
69
105
|
}
|
|
@@ -178,7 +214,7 @@ export class SyncClient {
|
|
|
178
214
|
const entry = { ...persisted, status: 'pending' };
|
|
179
215
|
this.pending.set(event.eventGuid, entry);
|
|
180
216
|
this.options.store.applyOptimistic(event);
|
|
181
|
-
this.
|
|
217
|
+
this.armFailureTimer(event.eventGuid);
|
|
182
218
|
this.trySend(entry);
|
|
183
219
|
this.updatePendingState();
|
|
184
220
|
this.options.onSafetyChange?.();
|
|
@@ -219,10 +255,100 @@ export class SyncClient {
|
|
|
219
255
|
return () => this.subscribers.delete(listener);
|
|
220
256
|
}
|
|
221
257
|
installRuntimeListeners() {
|
|
222
|
-
this.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
258
|
+
const runtime = this.options.runtime;
|
|
259
|
+
// Loaded hidden (a background tab): the spell starts now; the first
|
|
260
|
+
// connect still runs, and its failure goes dormant rather than retrying.
|
|
261
|
+
if (runtime.visibility() === 'hidden') {
|
|
262
|
+
this.hiddenSince = this.now();
|
|
263
|
+
this.spellCounters = this.snapshot();
|
|
264
|
+
}
|
|
265
|
+
this.removeListeners.push(runtime.onVisibilityChange(() => {
|
|
266
|
+
if (runtime.visibility() === 'visible')
|
|
267
|
+
this.onBecameVisible();
|
|
268
|
+
else
|
|
269
|
+
this.onBecameHidden();
|
|
270
|
+
}), runtime.onOnline(() => this.onOnline()), runtime.onPageHide(() => this.flushViaBeacon()));
|
|
271
|
+
const onFreeze = runtime.onFreeze?.(() => this.counters.freezes++);
|
|
272
|
+
const onResume = runtime.onResume?.(() => this.counters.resumes++);
|
|
273
|
+
if (onFreeze)
|
|
274
|
+
this.removeListeners.push(onFreeze);
|
|
275
|
+
if (onResume)
|
|
276
|
+
this.removeListeners.push(onResume);
|
|
277
|
+
}
|
|
278
|
+
isHidden() {
|
|
279
|
+
return this.hiddenSince !== null;
|
|
280
|
+
}
|
|
281
|
+
now() {
|
|
282
|
+
return this.options.runtime.now();
|
|
283
|
+
}
|
|
284
|
+
/** Counters with the running hidden spell's time folded in. */
|
|
285
|
+
snapshot() {
|
|
286
|
+
const hiddenMs = this.counters.hiddenMs + (this.hiddenSince === null ? 0 : Math.max(0, this.now() - this.hiddenSince));
|
|
287
|
+
return { ...this.counters, hiddenMs };
|
|
288
|
+
}
|
|
289
|
+
static delta(since, now) {
|
|
290
|
+
return {
|
|
291
|
+
hiddenMs: Math.max(0, now.hiddenMs - since.hiddenMs),
|
|
292
|
+
freezes: now.freezes - since.freezes,
|
|
293
|
+
resumes: now.resumes - since.resumes,
|
|
294
|
+
watchdogKills: now.watchdogKills - since.watchdogKills,
|
|
295
|
+
backgroundCatchups: now.backgroundCatchups - since.backgroundCatchups,
|
|
296
|
+
backgroundCatchupFailures: now.backgroundCatchupFailures - since.backgroundCatchupFailures,
|
|
297
|
+
backgroundSocketAttempts: now.backgroundSocketAttempts - since.backgroundSocketAttempts,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
onBecameHidden() {
|
|
301
|
+
if (this.halted || this.disposed || this.hiddenSince !== null)
|
|
302
|
+
return;
|
|
303
|
+
this.finishReturnWindow();
|
|
304
|
+
this.hiddenSince = this.now();
|
|
305
|
+
this.spellCounters = this.snapshot();
|
|
306
|
+
this.hiddenSocketFailed = false;
|
|
307
|
+
this.beaconedWhileDormant = false;
|
|
308
|
+
if (this.connectReplyTimer !== null)
|
|
309
|
+
this.options.runtime.clearTimeout(this.connectReplyTimer);
|
|
310
|
+
this.connectReplyTimer = null;
|
|
311
|
+
// A ladder waiting to retry is cancelled: no reconnects while hidden.
|
|
312
|
+
if (this.socket === null)
|
|
313
|
+
this.enterDormant();
|
|
314
|
+
}
|
|
315
|
+
onBecameVisible() {
|
|
316
|
+
if (this.halted || this.disposed)
|
|
317
|
+
return;
|
|
318
|
+
const hiddenSince = this.hiddenSince;
|
|
319
|
+
if (hiddenSince !== null) {
|
|
320
|
+
const hiddenMs = Math.max(0, this.now() - hiddenSince);
|
|
321
|
+
const spell = this.spellCounters ?? this.snapshot();
|
|
322
|
+
this.counters.hiddenMs += hiddenMs;
|
|
323
|
+
this.hiddenSince = null;
|
|
324
|
+
this.spellCounters = null;
|
|
325
|
+
this.cancelBackgroundTick();
|
|
326
|
+
this.httpApplied.clear();
|
|
327
|
+
this.openReturnWindow(hiddenMs, spell);
|
|
328
|
+
this.rearmFailureTimers();
|
|
329
|
+
if (this.outageStartedAt !== null && !this.outageEscalated)
|
|
330
|
+
this.armEscalation();
|
|
331
|
+
// Each return starts the ladder afresh: failures from earlier glances
|
|
332
|
+
// (a phone shown for a second, often before its network is back) must
|
|
333
|
+
// not stretch this return's retry to 8-30 s or add up to a WARN.
|
|
334
|
+
this.reconnectAttempts = 0;
|
|
335
|
+
}
|
|
336
|
+
this.wakeConnection();
|
|
337
|
+
// Always: a socket that looks open may be dead (review S2), and a
|
|
338
|
+
// handshake may be slow. One HTTP round trip makes the page current.
|
|
339
|
+
void this.runCatchup('return');
|
|
340
|
+
}
|
|
341
|
+
onOnline() {
|
|
342
|
+
if (this.halted || this.disposed)
|
|
343
|
+
return;
|
|
344
|
+
if (this.isHidden()) {
|
|
345
|
+
if (this.socket === null) {
|
|
346
|
+
this.cancelBackgroundTick();
|
|
347
|
+
void this.backgroundTick();
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
this.wakeConnection();
|
|
226
352
|
}
|
|
227
353
|
wakeConnection() {
|
|
228
354
|
if (this.halted || this.disposed)
|
|
@@ -230,6 +356,15 @@ export class SyncClient {
|
|
|
230
356
|
const socket = this.socket;
|
|
231
357
|
if (socket?.readyState === SOCKET_OPEN) {
|
|
232
358
|
this.sendConnect();
|
|
359
|
+
// A socket that looks open may be half-dead: no catch-up reply in 5 s
|
|
360
|
+
// replaces it.
|
|
361
|
+
if (this.connectReplyTimer !== null)
|
|
362
|
+
this.options.runtime.clearTimeout(this.connectReplyTimer);
|
|
363
|
+
this.connectReplyTimer = this.options.runtime.setTimeout(() => {
|
|
364
|
+
this.connectReplyTimer = null;
|
|
365
|
+
if (this.socket === socket && !this.isHidden())
|
|
366
|
+
this.replaceSocket();
|
|
367
|
+
}, WAKE_CONNECT_REPLY_MS);
|
|
233
368
|
return;
|
|
234
369
|
}
|
|
235
370
|
// A handshake still inside its watchdog window may just be slow: leave
|
|
@@ -269,14 +404,23 @@ export class SyncClient {
|
|
|
269
404
|
catch (error) {
|
|
270
405
|
this.options.logger.warn('socket creation failed', { error });
|
|
271
406
|
this.startOutage(1006);
|
|
272
|
-
this.
|
|
407
|
+
if (this.isHidden())
|
|
408
|
+
this.enterDormant();
|
|
409
|
+
else
|
|
410
|
+
this.scheduleReconnect();
|
|
273
411
|
return;
|
|
274
412
|
}
|
|
275
413
|
this.socket = socket;
|
|
276
414
|
this.socketCreatedAt = this.options.runtime.now();
|
|
415
|
+
this.socketCreatedHidden = this.isHidden();
|
|
416
|
+
this.socketOpenedAt = null;
|
|
417
|
+
if (this.socketCreatedHidden)
|
|
418
|
+
this.counters.backgroundSocketAttempts++;
|
|
277
419
|
this.watchdogTimer = this.options.runtime.setTimeout(() => {
|
|
278
|
-
if (socket.readyState !== SOCKET_OPEN)
|
|
420
|
+
if (socket.readyState !== SOCKET_OPEN) {
|
|
421
|
+
this.counters.watchdogKills++;
|
|
279
422
|
socket.close();
|
|
423
|
+
}
|
|
280
424
|
}, CONNECT_WATCHDOG_MS);
|
|
281
425
|
socket.onopen = () => {
|
|
282
426
|
if (this.socket !== socket) {
|
|
@@ -286,16 +430,21 @@ export class SyncClient {
|
|
|
286
430
|
if (this.watchdogTimer !== null)
|
|
287
431
|
this.options.runtime.clearTimeout(this.watchdogTimer);
|
|
288
432
|
this.watchdogTimer = null;
|
|
433
|
+
this.socketOpenedAt = this.now();
|
|
434
|
+
this.cancelBackgroundTick();
|
|
289
435
|
this.sendConnect();
|
|
290
436
|
this.pingTimer = this.options.runtime.setInterval(() => this.sendRaw({ type: 'ping' }), PING_INTERVAL_MS);
|
|
291
437
|
this.rolloverTimer = this.options.runtime.setTimeout(() => {
|
|
292
438
|
this.options.logger.info('ws rollover', {});
|
|
439
|
+
this.plannedClose = socket;
|
|
293
440
|
socket.close();
|
|
294
441
|
}, this.options.reconnectBeforeMs ?? DEFAULT_RECONNECT_BEFORE_MS);
|
|
295
442
|
};
|
|
296
443
|
socket.onmessage = (event) => {
|
|
297
|
-
if (this.socket
|
|
298
|
-
|
|
444
|
+
if (this.socket !== socket)
|
|
445
|
+
return;
|
|
446
|
+
this.lastContactAt = this.now();
|
|
447
|
+
this.handleMessage(String(event.data));
|
|
299
448
|
};
|
|
300
449
|
socket.onerror = () => {
|
|
301
450
|
// Browsers expose no useful detail; the close event owns recovery.
|
|
@@ -312,10 +461,219 @@ export class SyncClient {
|
|
|
312
461
|
this.halt();
|
|
313
462
|
return;
|
|
314
463
|
}
|
|
315
|
-
this.
|
|
316
|
-
this.
|
|
464
|
+
const planned = this.plannedClose === socket;
|
|
465
|
+
this.plannedClose = null;
|
|
466
|
+
this.startOutage(event.code, event.wasClean === true);
|
|
467
|
+
if (!this.isHidden()) {
|
|
468
|
+
this.scheduleReconnect();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (planned) {
|
|
472
|
+
// Our own rollover: this spell's sockets have lasted; reconnect once.
|
|
473
|
+
this.connectNow();
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const openedAt = this.socketOpenedAt;
|
|
477
|
+
if (this.socketCreatedHidden && (openedAt === null || this.now() - openedAt < HIDDEN_SOCKET_MIN_LIFE_MS)) {
|
|
478
|
+
this.hiddenSocketFailed = true;
|
|
479
|
+
}
|
|
480
|
+
this.enterDormant();
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
/** Hidden and disconnected: no WebSocket retries; HTTP catch-ups on a slow
|
|
484
|
+
* cadence instead (docs/SYNC-BACKGROUND-PLAN.md). */
|
|
485
|
+
enterDormant() {
|
|
486
|
+
if (this.halted || this.disposed)
|
|
487
|
+
return;
|
|
488
|
+
if (this.reconnectTimer !== null)
|
|
489
|
+
this.options.runtime.clearTimeout(this.reconnectTimer);
|
|
490
|
+
if (this.countdownTimer !== null)
|
|
491
|
+
this.options.runtime.clearInterval(this.countdownTimer);
|
|
492
|
+
this.reconnectTimer = null;
|
|
493
|
+
this.countdownTimer = null;
|
|
494
|
+
this.publish({ kind: 'disconnected', pending: this.pendingCount() });
|
|
495
|
+
if (!this.options.postEvents && !this.beaconedWhileDormant && this.unsettledPending().length > 0) {
|
|
496
|
+
this.beaconedWhileDormant = true;
|
|
497
|
+
this.flushViaBeacon();
|
|
498
|
+
}
|
|
499
|
+
if (this.backgroundTickTimer !== null)
|
|
500
|
+
return;
|
|
501
|
+
const interval = this.options.backgroundCatchupMs ?? DEFAULT_BACKGROUND_CATCHUP_MS;
|
|
502
|
+
const due = this.lastTickAt === null ? 0 : Math.max(0, this.lastTickAt + interval - this.now());
|
|
503
|
+
this.backgroundTickTimer = this.options.runtime.setTimeout(() => {
|
|
504
|
+
this.backgroundTickTimer = null;
|
|
505
|
+
void this.backgroundTick();
|
|
506
|
+
}, Math.min(due, interval));
|
|
507
|
+
}
|
|
508
|
+
cancelBackgroundTick() {
|
|
509
|
+
if (this.backgroundTickTimer !== null)
|
|
510
|
+
this.options.runtime.clearTimeout(this.backgroundTickTimer);
|
|
511
|
+
this.backgroundTickTimer = null;
|
|
512
|
+
}
|
|
513
|
+
async backgroundTick() {
|
|
514
|
+
if (this.halted || this.disposed || !this.isHidden() || this.socket !== null)
|
|
515
|
+
return;
|
|
516
|
+
this.lastTickAt = this.now();
|
|
517
|
+
await this.postPendingOverHttp();
|
|
518
|
+
const ok = await this.runCatchup('background');
|
|
519
|
+
if (this.halted || this.disposed || !this.isHidden() || this.socket !== null)
|
|
520
|
+
return;
|
|
521
|
+
if (ok && !this.hiddenSocketFailed) {
|
|
522
|
+
// Desktop hidden sockets usually live; one attempt per tick, none after
|
|
523
|
+
// the first hidden failure in this spell (phones cut them in seconds).
|
|
524
|
+
this.connectNow();
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
this.enterDormant();
|
|
528
|
+
}
|
|
529
|
+
unsettledPending() {
|
|
530
|
+
return [...this.pending.values()].filter((entry) => !this.settledPending.has(entry.event.eventGuid));
|
|
531
|
+
}
|
|
532
|
+
async postPendingOverHttp() {
|
|
533
|
+
const postEvents = this.options.postEvents;
|
|
534
|
+
if (!postEvents)
|
|
535
|
+
return;
|
|
536
|
+
const events = this.unsettledPending()
|
|
537
|
+
.filter((entry) => !this.httpApplied.has(entry.event.eventGuid))
|
|
538
|
+
.map((entry) => entry.event);
|
|
539
|
+
if (events.length === 0)
|
|
540
|
+
return;
|
|
541
|
+
try {
|
|
542
|
+
const { outcomes } = await this.withTimeout((signal) => postEvents({ clientGuid: this.options.clientGuid, events }, signal));
|
|
543
|
+
if (this.stopBeforeStoreMutation())
|
|
544
|
+
return;
|
|
545
|
+
for (const outcome of outcomes) {
|
|
546
|
+
if (outcome.status === 'rejected') {
|
|
547
|
+
this.handleRejection({
|
|
548
|
+
type: 'rejection',
|
|
549
|
+
eventGuid: outcome.eventGuid,
|
|
550
|
+
error: outcome.error ?? { code: 'rejected', message: 'rejected' },
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
// Applied (or a duplicate): the catch-up's echo confirms it.
|
|
555
|
+
this.httpApplied.add(outcome.eventGuid);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
// Offline or timed out: the next tick, or the socket on return, retries.
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/** The background/return catch-up (review S5): one slot, a return aborts
|
|
564
|
+
* a background fetch in flight, both give up after 15 s. */
|
|
565
|
+
async runCatchup(kind) {
|
|
566
|
+
if (this.halted || this.disposed)
|
|
567
|
+
return false;
|
|
568
|
+
if (this.catchupSlot !== null) {
|
|
569
|
+
if (kind === 'return' && this.catchupSlot.kind === 'background')
|
|
570
|
+
this.catchupSlot.controller.abort();
|
|
571
|
+
else
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
const controller = new AbortController();
|
|
575
|
+
const slot = { kind, controller };
|
|
576
|
+
this.catchupSlot = slot;
|
|
577
|
+
this.abortControllers.add(controller);
|
|
578
|
+
const timeout = this.options.runtime.setTimeout(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
|
|
579
|
+
let ok = false;
|
|
580
|
+
try {
|
|
581
|
+
const result = await this.options.fetchCatchup(this.options.store.currentSeq(), controller.signal);
|
|
582
|
+
if (controller.signal.aborted || this.stopBeforeStoreMutation())
|
|
583
|
+
return false;
|
|
584
|
+
ok = true;
|
|
585
|
+
this.lastContactAt = this.now();
|
|
586
|
+
if (result.buildId)
|
|
587
|
+
this.options.onBuildObserved?.(result.buildId);
|
|
588
|
+
else if (kind === 'background')
|
|
589
|
+
this.options.onBuildChanged?.();
|
|
590
|
+
if (result.reload) {
|
|
591
|
+
this.requestReload('server');
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
for (const event of result.events)
|
|
595
|
+
this.handleServerEvent(event);
|
|
596
|
+
}
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
finally {
|
|
603
|
+
this.options.runtime.clearTimeout(timeout);
|
|
604
|
+
this.abortControllers.delete(controller);
|
|
605
|
+
if (this.catchupSlot === slot)
|
|
606
|
+
this.catchupSlot = null;
|
|
607
|
+
if (kind === 'background') {
|
|
608
|
+
if (ok)
|
|
609
|
+
this.counters.backgroundCatchups++;
|
|
610
|
+
else
|
|
611
|
+
this.counters.backgroundCatchupFailures++;
|
|
612
|
+
}
|
|
613
|
+
if (kind === 'return' && this.returnWindow !== null) {
|
|
614
|
+
this.returnWindow.catchupDone = true;
|
|
615
|
+
this.returnWindow.catchupOk = ok;
|
|
616
|
+
this.maybeFinishReturnWindow();
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
async withTimeout(operation) {
|
|
621
|
+
const controller = new AbortController();
|
|
622
|
+
this.abortControllers.add(controller);
|
|
623
|
+
const timeout = this.options.runtime.setTimeout(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
|
|
624
|
+
try {
|
|
625
|
+
return await operation(controller.signal);
|
|
626
|
+
}
|
|
627
|
+
finally {
|
|
628
|
+
this.options.runtime.clearTimeout(timeout);
|
|
629
|
+
this.abortControllers.delete(controller);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
openReturnWindow(hiddenMs, spell) {
|
|
633
|
+
this.finishReturnWindow();
|
|
634
|
+
const now = this.now();
|
|
635
|
+
this.returnWindow = {
|
|
636
|
+
visibleAt: now,
|
|
637
|
+
hiddenMs,
|
|
638
|
+
socketOpenAtReturn: this.socket?.readyState === SOCKET_OPEN,
|
|
639
|
+
sinceLastContactMs: this.lastContactAt === null ? null : Math.max(0, now - this.lastContactAt),
|
|
640
|
+
spell: SyncClient.delta(spell, this.snapshot()),
|
|
641
|
+
establishedAt: null,
|
|
642
|
+
catchupDone: false,
|
|
643
|
+
catchupOk: false,
|
|
644
|
+
eventsOnReturn: 0,
|
|
645
|
+
resyncOnReturn: 0,
|
|
646
|
+
timer: this.options.runtime.setTimeout(() => this.finishReturnWindow(), RETURN_REPORT_TIMEOUT_MS),
|
|
317
647
|
};
|
|
318
648
|
}
|
|
649
|
+
maybeFinishReturnWindow() {
|
|
650
|
+
const window = this.returnWindow;
|
|
651
|
+
if (window !== null && window.establishedAt !== null && window.catchupDone)
|
|
652
|
+
this.finishReturnWindow();
|
|
653
|
+
}
|
|
654
|
+
/** One INFO per hidden spell: what the user saw change on return. */
|
|
655
|
+
finishReturnWindow() {
|
|
656
|
+
const window = this.returnWindow;
|
|
657
|
+
if (window === null)
|
|
658
|
+
return;
|
|
659
|
+
this.returnWindow = null;
|
|
660
|
+
this.options.runtime.clearTimeout(window.timer);
|
|
661
|
+
this.options.logger.info('page returned to foreground', {
|
|
662
|
+
hiddenMs: window.hiddenMs,
|
|
663
|
+
socketOpenAtReturn: window.socketOpenAtReturn,
|
|
664
|
+
reconnectMs: window.establishedAt === null ? null : Math.max(0, window.establishedAt - window.visibleAt),
|
|
665
|
+
catchupOk: window.catchupOk,
|
|
666
|
+
eventsOnReturn: window.eventsOnReturn,
|
|
667
|
+
resyncOnReturn: window.resyncOnReturn,
|
|
668
|
+
sinceLastContactMs: window.sinceLastContactMs,
|
|
669
|
+
backgroundCatchups: window.spell.backgroundCatchups,
|
|
670
|
+
backgroundCatchupFailures: window.spell.backgroundCatchupFailures,
|
|
671
|
+
backgroundSocketAttempts: window.spell.backgroundSocketAttempts,
|
|
672
|
+
watchdogKills: window.spell.watchdogKills,
|
|
673
|
+
freezes: window.spell.freezes,
|
|
674
|
+
resumes: window.spell.resumes,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
319
677
|
connectNow() {
|
|
320
678
|
if (this.reconnectTimer !== null)
|
|
321
679
|
this.options.runtime.clearTimeout(this.reconnectTimer);
|
|
@@ -328,6 +686,7 @@ export class SyncClient {
|
|
|
328
686
|
scheduleReconnect() {
|
|
329
687
|
const delay = RECONNECT_DELAYS_SECONDS[Math.min(this.reconnectAttempts, RECONNECT_DELAYS_SECONDS.length - 1)];
|
|
330
688
|
this.reconnectAttempts++;
|
|
689
|
+
this.totalAttempts++;
|
|
331
690
|
if (this.reconnectAttempts >= PROBE_AFTER_ATTEMPTS)
|
|
332
691
|
void this.probeSession();
|
|
333
692
|
let remaining = delay;
|
|
@@ -371,13 +730,23 @@ export class SyncClient {
|
|
|
371
730
|
this.probeInFlight = false;
|
|
372
731
|
}
|
|
373
732
|
}
|
|
374
|
-
startOutage(closeCode) {
|
|
733
|
+
startOutage(closeCode, wasClean = false) {
|
|
375
734
|
if (this.outageStartedAt !== null)
|
|
376
735
|
return;
|
|
377
736
|
this.outageStartedAt = this.options.runtime.now();
|
|
378
737
|
this.outageEscalated = false;
|
|
738
|
+
this.outageCloseCode = closeCode;
|
|
739
|
+
this.outageInfo = { closeCode, wasClean, visibleAtDrop: !this.isHidden(), counters: this.snapshot() };
|
|
740
|
+
this.armEscalation();
|
|
741
|
+
}
|
|
742
|
+
/** ERROR only after OUTAGE_ESCALATE_MS of visible, on-time outage; re-armed
|
|
743
|
+
* on each return so an outage that began hidden still escalates (S3). */
|
|
744
|
+
armEscalation() {
|
|
745
|
+
if (this.escalateTimer !== null)
|
|
746
|
+
this.options.runtime.clearTimeout(this.escalateTimer);
|
|
379
747
|
this.escalateArmedAt = this.options.runtime.now();
|
|
380
748
|
this.escalateTimer = this.options.runtime.setTimeout(() => {
|
|
749
|
+
this.escalateTimer = null;
|
|
381
750
|
const late = this.options.runtime.now() - this.escalateArmedAt - OUTAGE_ESCALATE_MS;
|
|
382
751
|
if (this.options.runtime.visibility() === 'hidden' || late > 5000)
|
|
383
752
|
return;
|
|
@@ -385,7 +754,7 @@ export class SyncClient {
|
|
|
385
754
|
this.outageEscalated = true;
|
|
386
755
|
this.options.logger.error('sync outage ongoing', {
|
|
387
756
|
durationMs: this.options.runtime.now() - this.outageStartedAt,
|
|
388
|
-
closeCode,
|
|
757
|
+
closeCode: this.outageCloseCode,
|
|
389
758
|
attempts: this.reconnectAttempts,
|
|
390
759
|
});
|
|
391
760
|
}
|
|
@@ -415,6 +784,9 @@ export class SyncClient {
|
|
|
415
784
|
this.handleSocketCatchup(message);
|
|
416
785
|
return;
|
|
417
786
|
case 'reload':
|
|
787
|
+
if (this.connectReplyTimer !== null)
|
|
788
|
+
this.options.runtime.clearTimeout(this.connectReplyTimer);
|
|
789
|
+
this.connectReplyTimer = null;
|
|
418
790
|
this.requestReload('server');
|
|
419
791
|
return;
|
|
420
792
|
case 'event':
|
|
@@ -426,6 +798,9 @@ export class SyncClient {
|
|
|
426
798
|
}
|
|
427
799
|
}
|
|
428
800
|
handleSocketCatchup(message) {
|
|
801
|
+
if (this.connectReplyTimer !== null)
|
|
802
|
+
this.options.runtime.clearTimeout(this.connectReplyTimer);
|
|
803
|
+
this.connectReplyTimer = null;
|
|
429
804
|
if (message.buildId)
|
|
430
805
|
this.options.onBuildObserved?.(message.buildId);
|
|
431
806
|
let complete = true;
|
|
@@ -446,8 +821,12 @@ export class SyncClient {
|
|
|
446
821
|
return false;
|
|
447
822
|
const currentSeq = this.options.store.currentSeq();
|
|
448
823
|
if (event.seq <= currentSeq) {
|
|
449
|
-
|
|
824
|
+
// A second echo of an already-confirmed edit (socket and HTTP catch-up
|
|
825
|
+
// both deliver it on return) must not upgrade the settlement to a full
|
|
826
|
+
// reconcile (review S4).
|
|
827
|
+
if (this.pending.has(event.eventGuid) && !this.confirmedPending.has(event.eventGuid)) {
|
|
450
828
|
this.confirmPending(event.eventGuid, true);
|
|
829
|
+
}
|
|
451
830
|
return true;
|
|
452
831
|
}
|
|
453
832
|
if (event.seq > currentSeq + 1) {
|
|
@@ -470,6 +849,8 @@ export class SyncClient {
|
|
|
470
849
|
return false;
|
|
471
850
|
}
|
|
472
851
|
this.options.store.applyServerEvent(event);
|
|
852
|
+
if (this.returnWindow !== null && !this.pending.has(event.eventGuid))
|
|
853
|
+
this.returnWindow.eventsOnReturn++;
|
|
473
854
|
if (this.stopBeforeStoreMutation())
|
|
474
855
|
return false;
|
|
475
856
|
this.options.store.setCurrentSeq(event.seq);
|
|
@@ -641,6 +1022,8 @@ export class SyncClient {
|
|
|
641
1022
|
return true;
|
|
642
1023
|
}
|
|
643
1024
|
this.options.store.installBootstrap(bootstrap);
|
|
1025
|
+
if (this.returnWindow !== null)
|
|
1026
|
+
this.returnWindow.resyncOnReturn++;
|
|
644
1027
|
for (const [eventGuid, entry] of this.pending) {
|
|
645
1028
|
if (this.settledPending.has(eventGuid))
|
|
646
1029
|
continue;
|
|
@@ -655,7 +1038,24 @@ export class SyncClient {
|
|
|
655
1038
|
release();
|
|
656
1039
|
}
|
|
657
1040
|
}
|
|
1041
|
+
armFailureTimer(eventGuid) {
|
|
1042
|
+
this.clearFailureTimer(eventGuid);
|
|
1043
|
+
this.failureTimers.set(eventGuid, this.options.runtime.setTimeout(() => this.failUnconfirmed(eventGuid), FAILURE_TIMEOUT_MS));
|
|
1044
|
+
}
|
|
1045
|
+
/** On return: a verdict needs 60 s of visible time (review B2). */
|
|
1046
|
+
rearmFailureTimers() {
|
|
1047
|
+
for (const [eventGuid, entry] of this.pending) {
|
|
1048
|
+
if (entry.recovered || this.settledPending.has(eventGuid) || this.unresolvedFailures.has(eventGuid))
|
|
1049
|
+
continue;
|
|
1050
|
+
this.armFailureTimer(eventGuid);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
658
1053
|
failUnconfirmed(eventGuid) {
|
|
1054
|
+
this.failureTimers.delete(eventGuid);
|
|
1055
|
+
// Hidden: no verdict and no re-arm (that would wake the page every
|
|
1056
|
+
// minute); the return re-arms a full 60 s.
|
|
1057
|
+
if (this.isHidden())
|
|
1058
|
+
return;
|
|
659
1059
|
const entry = this.pending.get(eventGuid);
|
|
660
1060
|
if (!entry || entry.recovered)
|
|
661
1061
|
return;
|
|
@@ -667,24 +1067,51 @@ export class SyncClient {
|
|
|
667
1067
|
}
|
|
668
1068
|
onSyncEstablished() {
|
|
669
1069
|
if (this.outageStartedAt !== null) {
|
|
670
|
-
const durationMs = this.options.runtime.now() - this.outageStartedAt;
|
|
1070
|
+
const durationMs = Math.max(0, this.options.runtime.now() - this.outageStartedAt);
|
|
1071
|
+
// Failures are only counted (and the ladder only runs) while visible,
|
|
1072
|
+
// and the count restarts on each return, so `attempts` is what the
|
|
1073
|
+
// user could have waited through; `totalAttempts` spans every spell.
|
|
671
1074
|
const attempts = this.reconnectAttempts;
|
|
1075
|
+
const info = this.outageInfo;
|
|
1076
|
+
const delta = info ? SyncClient.delta(info.counters, this.snapshot()) : null;
|
|
1077
|
+
const fields = {
|
|
1078
|
+
durationMs,
|
|
1079
|
+
attempts,
|
|
1080
|
+
totalAttempts: this.totalAttempts,
|
|
1081
|
+
closeCode: info?.closeCode,
|
|
1082
|
+
wasClean: info?.wasClean,
|
|
1083
|
+
visibleAtDrop: info?.visibleAtDrop,
|
|
1084
|
+
hiddenMs: delta?.hiddenMs,
|
|
1085
|
+
watchdogKills: delta?.watchdogKills,
|
|
1086
|
+
backgroundCatchups: delta?.backgroundCatchups,
|
|
1087
|
+
freezes: delta?.freezes,
|
|
1088
|
+
resumes: delta?.resumes,
|
|
1089
|
+
};
|
|
672
1090
|
if (this.outageEscalated) {
|
|
673
|
-
this.options.logger.info('sync outage recovered',
|
|
1091
|
+
this.options.logger.info('sync outage recovered', fields);
|
|
674
1092
|
}
|
|
675
1093
|
else if (attempts >= 2) {
|
|
676
|
-
this.options.logger.warn('sync outage recovered',
|
|
1094
|
+
this.options.logger.warn('sync outage recovered', fields);
|
|
677
1095
|
}
|
|
678
1096
|
else if (attempts === 1) {
|
|
679
|
-
this.options.logger.info('ws reconnected first try',
|
|
1097
|
+
this.options.logger.info('ws reconnected first try', fields);
|
|
1098
|
+
}
|
|
1099
|
+
else {
|
|
1100
|
+
this.options.logger.info('background sync outage recovered', fields);
|
|
680
1101
|
}
|
|
681
1102
|
this.outageStartedAt = null;
|
|
682
1103
|
this.outageEscalated = false;
|
|
1104
|
+
this.outageInfo = null;
|
|
1105
|
+
}
|
|
1106
|
+
if (this.returnWindow !== null && this.returnWindow.establishedAt === null) {
|
|
1107
|
+
this.returnWindow.establishedAt = this.now();
|
|
1108
|
+
this.maybeFinishReturnWindow();
|
|
683
1109
|
}
|
|
684
1110
|
if (this.escalateTimer !== null)
|
|
685
1111
|
this.options.runtime.clearTimeout(this.escalateTimer);
|
|
686
1112
|
this.escalateTimer = null;
|
|
687
1113
|
this.reconnectAttempts = 0;
|
|
1114
|
+
this.totalAttempts = 0;
|
|
688
1115
|
for (const [eventGuid, entry] of this.pending) {
|
|
689
1116
|
if (this.settledPending.has(eventGuid)) {
|
|
690
1117
|
this.retrySettlement(eventGuid);
|
|
@@ -900,6 +1327,9 @@ export class SyncClient {
|
|
|
900
1327
|
this.publish({ kind: 'expired', pending: this.pendingCount() });
|
|
901
1328
|
}
|
|
902
1329
|
clearConnectionTimers() {
|
|
1330
|
+
if (this.connectReplyTimer !== null)
|
|
1331
|
+
this.options.runtime.clearTimeout(this.connectReplyTimer);
|
|
1332
|
+
this.connectReplyTimer = null;
|
|
903
1333
|
if (this.pingTimer !== null)
|
|
904
1334
|
this.options.runtime.clearInterval(this.pingTimer);
|
|
905
1335
|
if (this.watchdogTimer !== null)
|
|
@@ -922,6 +1352,10 @@ export class SyncClient {
|
|
|
922
1352
|
this.options.runtime.clearTimeout(this.resyncBackoffTimer);
|
|
923
1353
|
for (const timer of this.failureTimers.values())
|
|
924
1354
|
this.options.runtime.clearTimeout(timer);
|
|
1355
|
+
this.cancelBackgroundTick();
|
|
1356
|
+
if (this.returnWindow !== null)
|
|
1357
|
+
this.options.runtime.clearTimeout(this.returnWindow.timer);
|
|
1358
|
+
this.returnWindow = null;
|
|
925
1359
|
this.reconnectTimer = null;
|
|
926
1360
|
this.countdownTimer = null;
|
|
927
1361
|
this.escalateTimer = null;
|
package/package.json
CHANGED