@steve31415/baselib 3.19.0 → 3.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -50,6 +50,11 @@ export declare class BuildUpdateMonitor {
|
|
|
50
50
|
private markAvailable;
|
|
51
51
|
private maybeReload;
|
|
52
52
|
private setState;
|
|
53
|
+
/** Polls only while visible: an interval in a hidden page would wake it
|
|
54
|
+
* every few minutes to do nothing. Hidden pages learn of deploys from the
|
|
55
|
+
* sync client's pushes and background catch-ups. */
|
|
56
|
+
private startPolling;
|
|
57
|
+
private stopPolling;
|
|
53
58
|
private onVisibility;
|
|
54
59
|
private onOnline;
|
|
55
60
|
}
|
|
@@ -32,10 +32,8 @@ export class BuildUpdateMonitor {
|
|
|
32
32
|
this.started = true;
|
|
33
33
|
this.lifecycle += 1;
|
|
34
34
|
this.stopSafety = this.deps.safety.subscribe(() => this.maybeReload());
|
|
35
|
-
|
|
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;
|
|
@@ -116,6 +132,7 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
116
132
|
private countdownTimer;
|
|
117
133
|
private pingTimer;
|
|
118
134
|
private watchdogTimer;
|
|
135
|
+
private socketCreatedAt;
|
|
119
136
|
private rolloverTimer;
|
|
120
137
|
private escalateTimer;
|
|
121
138
|
private escalateArmedAt;
|
|
@@ -133,6 +150,23 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
133
150
|
private reconciliationTail;
|
|
134
151
|
private initialization;
|
|
135
152
|
private disposed;
|
|
153
|
+
private hiddenSince;
|
|
154
|
+
private hiddenSocketFailed;
|
|
155
|
+
private beaconedWhileDormant;
|
|
156
|
+
private backgroundTickTimer;
|
|
157
|
+
private lastTickAt;
|
|
158
|
+
private catchupSlot;
|
|
159
|
+
private readonly httpApplied;
|
|
160
|
+
private connectReplyTimer;
|
|
161
|
+
private socketCreatedHidden;
|
|
162
|
+
private socketOpenedAt;
|
|
163
|
+
private plannedClose;
|
|
164
|
+
private outageCloseCode;
|
|
165
|
+
private outageInfo;
|
|
166
|
+
private lastContactAt;
|
|
167
|
+
private spellCounters;
|
|
168
|
+
private returnWindow;
|
|
169
|
+
private readonly counters;
|
|
136
170
|
constructor(options: SyncClientOptions<B, C, S, Summary>);
|
|
137
171
|
init(): Promise<void>;
|
|
138
172
|
private initialize;
|
|
@@ -153,12 +187,41 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
153
187
|
state(): SyncClientState;
|
|
154
188
|
subscribe(listener: (state: SyncClientState) => void): () => void;
|
|
155
189
|
private installRuntimeListeners;
|
|
190
|
+
private isHidden;
|
|
191
|
+
private now;
|
|
192
|
+
/** Counters with the running hidden spell's time folded in. */
|
|
193
|
+
private snapshot;
|
|
194
|
+
private static delta;
|
|
195
|
+
private onBecameHidden;
|
|
196
|
+
private onBecameVisible;
|
|
197
|
+
private onOnline;
|
|
156
198
|
private wakeConnection;
|
|
199
|
+
/** Drops the current socket, if any, without treating it as an outage, and
|
|
200
|
+
* connects at once. */
|
|
201
|
+
private replaceSocket;
|
|
157
202
|
private connect;
|
|
203
|
+
/** Hidden and disconnected: no WebSocket retries; HTTP catch-ups on a slow
|
|
204
|
+
* cadence instead (docs/SYNC-BACKGROUND-PLAN.md). */
|
|
205
|
+
private enterDormant;
|
|
206
|
+
private cancelBackgroundTick;
|
|
207
|
+
private backgroundTick;
|
|
208
|
+
private unsettledPending;
|
|
209
|
+
private postPendingOverHttp;
|
|
210
|
+
/** The background/return catch-up (review S5): one slot, a return aborts
|
|
211
|
+
* a background fetch in flight, both give up after 15 s. */
|
|
212
|
+
private runCatchup;
|
|
213
|
+
private withTimeout;
|
|
214
|
+
private openReturnWindow;
|
|
215
|
+
private maybeFinishReturnWindow;
|
|
216
|
+
/** One INFO per hidden spell: what the user saw change on return. */
|
|
217
|
+
private finishReturnWindow;
|
|
158
218
|
private connectNow;
|
|
159
219
|
private scheduleReconnect;
|
|
160
220
|
private probeSession;
|
|
161
221
|
private startOutage;
|
|
222
|
+
/** ERROR only after OUTAGE_ESCALATE_MS of visible, on-time outage; re-armed
|
|
223
|
+
* on each return so an outage that began hidden still escalates (S3). */
|
|
224
|
+
private armEscalation;
|
|
162
225
|
private handleMessage;
|
|
163
226
|
private handleSocketCatchup;
|
|
164
227
|
/** Returns false when a forward gap prevented application. */
|
|
@@ -170,6 +233,9 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
170
233
|
private finishConfirmation;
|
|
171
234
|
private finishRejection;
|
|
172
235
|
private reconcileStore;
|
|
236
|
+
private armFailureTimer;
|
|
237
|
+
/** On return: a verdict needs 60 s of visible time (review B2). */
|
|
238
|
+
private rearmFailureTimers;
|
|
173
239
|
private failUnconfirmed;
|
|
174
240
|
private onSyncEstablished;
|
|
175
241
|
private requestReload;
|
|
@@ -15,6 +15,13 @@ const GAP_BUFFER_MAX = 1_000;
|
|
|
15
15
|
const RECONCILE_MOVED_MAX_REFETCHES = 3;
|
|
16
16
|
const CLOSE_NO_USER = 4403;
|
|
17
17
|
const PROBE_AFTER_ATTEMPTS = 2;
|
|
18
|
+
const DEFAULT_BACKGROUND_CATCHUP_MS = 5 * 60 * 1000;
|
|
19
|
+
const BACKGROUND_FETCH_TIMEOUT_MS = 15_000;
|
|
20
|
+
const WAKE_CONNECT_REPLY_MS = 5_000;
|
|
21
|
+
/** A hidden attempt's socket that dies sooner than this counts as failed. */
|
|
22
|
+
const HIDDEN_SOCKET_MIN_LIFE_MS = 60_000;
|
|
23
|
+
const RETURN_REPORT_TIMEOUT_MS = 15_000;
|
|
24
|
+
const SOCKET_CONNECTING = 0;
|
|
18
25
|
const SOCKET_OPEN = 1;
|
|
19
26
|
class LifecycleAbort {
|
|
20
27
|
reason;
|
|
@@ -45,6 +52,7 @@ export class SyncClient {
|
|
|
45
52
|
countdownTimer = null;
|
|
46
53
|
pingTimer = null;
|
|
47
54
|
watchdogTimer = null;
|
|
55
|
+
socketCreatedAt = 0;
|
|
48
56
|
rolloverTimer = null;
|
|
49
57
|
escalateTimer = null;
|
|
50
58
|
escalateArmedAt = 0;
|
|
@@ -62,6 +70,32 @@ export class SyncClient {
|
|
|
62
70
|
reconciliationTail = Promise.resolve();
|
|
63
71
|
initialization = null;
|
|
64
72
|
disposed = false;
|
|
73
|
+
// Hidden-page state (docs/SYNC-BACKGROUND-PLAN.md).
|
|
74
|
+
hiddenSince = null;
|
|
75
|
+
hiddenSocketFailed = false;
|
|
76
|
+
beaconedWhileDormant = false;
|
|
77
|
+
backgroundTickTimer = null;
|
|
78
|
+
lastTickAt = null;
|
|
79
|
+
catchupSlot = null;
|
|
80
|
+
httpApplied = new Set();
|
|
81
|
+
connectReplyTimer = null;
|
|
82
|
+
socketCreatedHidden = false;
|
|
83
|
+
socketOpenedAt = null;
|
|
84
|
+
plannedClose = null;
|
|
85
|
+
outageCloseCode = 1006;
|
|
86
|
+
outageInfo = null;
|
|
87
|
+
lastContactAt = null;
|
|
88
|
+
spellCounters = null;
|
|
89
|
+
returnWindow = null;
|
|
90
|
+
counters = {
|
|
91
|
+
hiddenMs: 0,
|
|
92
|
+
freezes: 0,
|
|
93
|
+
resumes: 0,
|
|
94
|
+
watchdogKills: 0,
|
|
95
|
+
backgroundCatchups: 0,
|
|
96
|
+
backgroundCatchupFailures: 0,
|
|
97
|
+
backgroundSocketAttempts: 0,
|
|
98
|
+
};
|
|
65
99
|
constructor(options) {
|
|
66
100
|
this.options = options;
|
|
67
101
|
}
|
|
@@ -176,7 +210,7 @@ export class SyncClient {
|
|
|
176
210
|
const entry = { ...persisted, status: 'pending' };
|
|
177
211
|
this.pending.set(event.eventGuid, entry);
|
|
178
212
|
this.options.store.applyOptimistic(event);
|
|
179
|
-
this.
|
|
213
|
+
this.armFailureTimer(event.eventGuid);
|
|
180
214
|
this.trySend(entry);
|
|
181
215
|
this.updatePendingState();
|
|
182
216
|
this.options.onSafetyChange?.();
|
|
@@ -207,14 +241,7 @@ export class SyncClient {
|
|
|
207
241
|
if (this.halted || this.disposed)
|
|
208
242
|
return;
|
|
209
243
|
this.options.logger.info('sync reconnect for traffic build');
|
|
210
|
-
|
|
211
|
-
this.socket = null;
|
|
212
|
-
this.clearConnectionTimers();
|
|
213
|
-
if (previous) {
|
|
214
|
-
previous.close();
|
|
215
|
-
this.detachSocket(previous);
|
|
216
|
-
}
|
|
217
|
-
this.connectNow();
|
|
244
|
+
this.replaceSocket();
|
|
218
245
|
}
|
|
219
246
|
state() {
|
|
220
247
|
return this.currentState;
|
|
@@ -224,19 +251,136 @@ export class SyncClient {
|
|
|
224
251
|
return () => this.subscribers.delete(listener);
|
|
225
252
|
}
|
|
226
253
|
installRuntimeListeners() {
|
|
227
|
-
this.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
254
|
+
const runtime = this.options.runtime;
|
|
255
|
+
// Loaded hidden (a background tab): the spell starts now; the first
|
|
256
|
+
// connect still runs, and its failure goes dormant rather than retrying.
|
|
257
|
+
if (runtime.visibility() === 'hidden') {
|
|
258
|
+
this.hiddenSince = this.now();
|
|
259
|
+
this.spellCounters = this.snapshot();
|
|
260
|
+
}
|
|
261
|
+
this.removeListeners.push(runtime.onVisibilityChange(() => {
|
|
262
|
+
if (runtime.visibility() === 'visible')
|
|
263
|
+
this.onBecameVisible();
|
|
264
|
+
else
|
|
265
|
+
this.onBecameHidden();
|
|
266
|
+
}), runtime.onOnline(() => this.onOnline()), runtime.onPageHide(() => this.flushViaBeacon()));
|
|
267
|
+
const onFreeze = runtime.onFreeze?.(() => this.counters.freezes++);
|
|
268
|
+
const onResume = runtime.onResume?.(() => this.counters.resumes++);
|
|
269
|
+
if (onFreeze)
|
|
270
|
+
this.removeListeners.push(onFreeze);
|
|
271
|
+
if (onResume)
|
|
272
|
+
this.removeListeners.push(onResume);
|
|
273
|
+
}
|
|
274
|
+
isHidden() {
|
|
275
|
+
return this.hiddenSince !== null;
|
|
276
|
+
}
|
|
277
|
+
now() {
|
|
278
|
+
return this.options.runtime.now();
|
|
279
|
+
}
|
|
280
|
+
/** Counters with the running hidden spell's time folded in. */
|
|
281
|
+
snapshot() {
|
|
282
|
+
const hiddenMs = this.counters.hiddenMs + (this.hiddenSince === null ? 0 : Math.max(0, this.now() - this.hiddenSince));
|
|
283
|
+
return { ...this.counters, hiddenMs };
|
|
284
|
+
}
|
|
285
|
+
static delta(since, now) {
|
|
286
|
+
return {
|
|
287
|
+
hiddenMs: Math.max(0, now.hiddenMs - since.hiddenMs),
|
|
288
|
+
freezes: now.freezes - since.freezes,
|
|
289
|
+
resumes: now.resumes - since.resumes,
|
|
290
|
+
watchdogKills: now.watchdogKills - since.watchdogKills,
|
|
291
|
+
backgroundCatchups: now.backgroundCatchups - since.backgroundCatchups,
|
|
292
|
+
backgroundCatchupFailures: now.backgroundCatchupFailures - since.backgroundCatchupFailures,
|
|
293
|
+
backgroundSocketAttempts: now.backgroundSocketAttempts - since.backgroundSocketAttempts,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
onBecameHidden() {
|
|
297
|
+
if (this.halted || this.disposed || this.hiddenSince !== null)
|
|
298
|
+
return;
|
|
299
|
+
this.finishReturnWindow();
|
|
300
|
+
this.hiddenSince = this.now();
|
|
301
|
+
this.spellCounters = this.snapshot();
|
|
302
|
+
this.hiddenSocketFailed = false;
|
|
303
|
+
this.beaconedWhileDormant = false;
|
|
304
|
+
if (this.connectReplyTimer !== null)
|
|
305
|
+
this.options.runtime.clearTimeout(this.connectReplyTimer);
|
|
306
|
+
this.connectReplyTimer = null;
|
|
307
|
+
// A ladder waiting to retry is cancelled: no reconnects while hidden.
|
|
308
|
+
if (this.socket === null)
|
|
309
|
+
this.enterDormant();
|
|
310
|
+
}
|
|
311
|
+
onBecameVisible() {
|
|
312
|
+
if (this.halted || this.disposed)
|
|
313
|
+
return;
|
|
314
|
+
const hiddenSince = this.hiddenSince;
|
|
315
|
+
if (hiddenSince !== null) {
|
|
316
|
+
const hiddenMs = Math.max(0, this.now() - hiddenSince);
|
|
317
|
+
const spell = this.spellCounters ?? this.snapshot();
|
|
318
|
+
this.counters.hiddenMs += hiddenMs;
|
|
319
|
+
this.hiddenSince = null;
|
|
320
|
+
this.spellCounters = null;
|
|
321
|
+
this.cancelBackgroundTick();
|
|
322
|
+
this.httpApplied.clear();
|
|
323
|
+
this.openReturnWindow(hiddenMs, spell);
|
|
324
|
+
this.rearmFailureTimers();
|
|
325
|
+
if (this.outageStartedAt !== null && !this.outageEscalated)
|
|
326
|
+
this.armEscalation();
|
|
327
|
+
}
|
|
328
|
+
this.wakeConnection();
|
|
329
|
+
// Always: a socket that looks open may be dead (review S2), and a
|
|
330
|
+
// handshake may be slow. One HTTP round trip makes the page current.
|
|
331
|
+
void this.runCatchup('return');
|
|
332
|
+
}
|
|
333
|
+
onOnline() {
|
|
334
|
+
if (this.halted || this.disposed)
|
|
335
|
+
return;
|
|
336
|
+
if (this.isHidden()) {
|
|
337
|
+
if (this.socket === null) {
|
|
338
|
+
this.cancelBackgroundTick();
|
|
339
|
+
void this.backgroundTick();
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
this.wakeConnection();
|
|
231
344
|
}
|
|
232
345
|
wakeConnection() {
|
|
233
346
|
if (this.halted || this.disposed)
|
|
234
347
|
return;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
348
|
+
const socket = this.socket;
|
|
349
|
+
if (socket?.readyState === SOCKET_OPEN) {
|
|
350
|
+
this.sendConnect();
|
|
351
|
+
// A socket that looks open may be half-dead: no catch-up reply in 5 s
|
|
352
|
+
// replaces it.
|
|
353
|
+
if (this.connectReplyTimer !== null)
|
|
354
|
+
this.options.runtime.clearTimeout(this.connectReplyTimer);
|
|
355
|
+
this.connectReplyTimer = this.options.runtime.setTimeout(() => {
|
|
356
|
+
this.connectReplyTimer = null;
|
|
357
|
+
if (this.socket === socket && !this.isHidden())
|
|
358
|
+
this.replaceSocket();
|
|
359
|
+
}, WAKE_CONNECT_REPLY_MS);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
// A handshake still inside its watchdog window may just be slow: leave
|
|
363
|
+
// it. One past that window began before the page was frozen (a
|
|
364
|
+
// backgrounded phone tab) and its watchdog never ran; a socket already
|
|
365
|
+
// closing is gone too. Waiting on either routes the returning user
|
|
366
|
+
// through the watchdog's close and a full backoff step (16-30 s of
|
|
367
|
+
// "Reconnecting…" observed on reader, 2026-09-24).
|
|
368
|
+
if (socket?.readyState === SOCKET_CONNECTING &&
|
|
369
|
+
this.options.runtime.now() - this.socketCreatedAt < CONNECT_WATCHDOG_MS) {
|
|
238
370
|
return;
|
|
239
371
|
}
|
|
372
|
+
this.replaceSocket();
|
|
373
|
+
}
|
|
374
|
+
/** Drops the current socket, if any, without treating it as an outage, and
|
|
375
|
+
* connects at once. */
|
|
376
|
+
replaceSocket() {
|
|
377
|
+
const previous = this.socket;
|
|
378
|
+
this.socket = null;
|
|
379
|
+
this.clearConnectionTimers();
|
|
380
|
+
if (previous) {
|
|
381
|
+
previous.close();
|
|
382
|
+
this.detachSocket(previous);
|
|
383
|
+
}
|
|
240
384
|
this.connectNow();
|
|
241
385
|
}
|
|
242
386
|
connect() {
|
|
@@ -252,13 +396,23 @@ export class SyncClient {
|
|
|
252
396
|
catch (error) {
|
|
253
397
|
this.options.logger.warn('socket creation failed', { error });
|
|
254
398
|
this.startOutage(1006);
|
|
255
|
-
this.
|
|
399
|
+
if (this.isHidden())
|
|
400
|
+
this.enterDormant();
|
|
401
|
+
else
|
|
402
|
+
this.scheduleReconnect();
|
|
256
403
|
return;
|
|
257
404
|
}
|
|
258
405
|
this.socket = socket;
|
|
406
|
+
this.socketCreatedAt = this.options.runtime.now();
|
|
407
|
+
this.socketCreatedHidden = this.isHidden();
|
|
408
|
+
this.socketOpenedAt = null;
|
|
409
|
+
if (this.socketCreatedHidden)
|
|
410
|
+
this.counters.backgroundSocketAttempts++;
|
|
259
411
|
this.watchdogTimer = this.options.runtime.setTimeout(() => {
|
|
260
|
-
if (socket.readyState !== SOCKET_OPEN)
|
|
412
|
+
if (socket.readyState !== SOCKET_OPEN) {
|
|
413
|
+
this.counters.watchdogKills++;
|
|
261
414
|
socket.close();
|
|
415
|
+
}
|
|
262
416
|
}, CONNECT_WATCHDOG_MS);
|
|
263
417
|
socket.onopen = () => {
|
|
264
418
|
if (this.socket !== socket) {
|
|
@@ -268,16 +422,21 @@ export class SyncClient {
|
|
|
268
422
|
if (this.watchdogTimer !== null)
|
|
269
423
|
this.options.runtime.clearTimeout(this.watchdogTimer);
|
|
270
424
|
this.watchdogTimer = null;
|
|
425
|
+
this.socketOpenedAt = this.now();
|
|
426
|
+
this.cancelBackgroundTick();
|
|
271
427
|
this.sendConnect();
|
|
272
428
|
this.pingTimer = this.options.runtime.setInterval(() => this.sendRaw({ type: 'ping' }), PING_INTERVAL_MS);
|
|
273
429
|
this.rolloverTimer = this.options.runtime.setTimeout(() => {
|
|
274
430
|
this.options.logger.info('ws rollover', {});
|
|
431
|
+
this.plannedClose = socket;
|
|
275
432
|
socket.close();
|
|
276
433
|
}, this.options.reconnectBeforeMs ?? DEFAULT_RECONNECT_BEFORE_MS);
|
|
277
434
|
};
|
|
278
435
|
socket.onmessage = (event) => {
|
|
279
|
-
if (this.socket
|
|
280
|
-
|
|
436
|
+
if (this.socket !== socket)
|
|
437
|
+
return;
|
|
438
|
+
this.lastContactAt = this.now();
|
|
439
|
+
this.handleMessage(String(event.data));
|
|
281
440
|
};
|
|
282
441
|
socket.onerror = () => {
|
|
283
442
|
// Browsers expose no useful detail; the close event owns recovery.
|
|
@@ -294,10 +453,219 @@ export class SyncClient {
|
|
|
294
453
|
this.halt();
|
|
295
454
|
return;
|
|
296
455
|
}
|
|
297
|
-
this.
|
|
298
|
-
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();
|
|
299
473
|
};
|
|
300
474
|
}
|
|
475
|
+
/** Hidden and disconnected: no WebSocket retries; HTTP catch-ups on a slow
|
|
476
|
+
* cadence instead (docs/SYNC-BACKGROUND-PLAN.md). */
|
|
477
|
+
enterDormant() {
|
|
478
|
+
if (this.halted || this.disposed)
|
|
479
|
+
return;
|
|
480
|
+
if (this.reconnectTimer !== null)
|
|
481
|
+
this.options.runtime.clearTimeout(this.reconnectTimer);
|
|
482
|
+
if (this.countdownTimer !== null)
|
|
483
|
+
this.options.runtime.clearInterval(this.countdownTimer);
|
|
484
|
+
this.reconnectTimer = null;
|
|
485
|
+
this.countdownTimer = null;
|
|
486
|
+
this.publish({ kind: 'disconnected', pending: this.pendingCount() });
|
|
487
|
+
if (!this.options.postEvents && !this.beaconedWhileDormant && this.unsettledPending().length > 0) {
|
|
488
|
+
this.beaconedWhileDormant = true;
|
|
489
|
+
this.flushViaBeacon();
|
|
490
|
+
}
|
|
491
|
+
if (this.backgroundTickTimer !== null)
|
|
492
|
+
return;
|
|
493
|
+
const interval = this.options.backgroundCatchupMs ?? DEFAULT_BACKGROUND_CATCHUP_MS;
|
|
494
|
+
const due = this.lastTickAt === null ? 0 : Math.max(0, this.lastTickAt + interval - this.now());
|
|
495
|
+
this.backgroundTickTimer = this.options.runtime.setTimeout(() => {
|
|
496
|
+
this.backgroundTickTimer = null;
|
|
497
|
+
void this.backgroundTick();
|
|
498
|
+
}, Math.min(due, interval));
|
|
499
|
+
}
|
|
500
|
+
cancelBackgroundTick() {
|
|
501
|
+
if (this.backgroundTickTimer !== null)
|
|
502
|
+
this.options.runtime.clearTimeout(this.backgroundTickTimer);
|
|
503
|
+
this.backgroundTickTimer = null;
|
|
504
|
+
}
|
|
505
|
+
async backgroundTick() {
|
|
506
|
+
if (this.halted || this.disposed || !this.isHidden() || this.socket !== null)
|
|
507
|
+
return;
|
|
508
|
+
this.lastTickAt = this.now();
|
|
509
|
+
await this.postPendingOverHttp();
|
|
510
|
+
const ok = await this.runCatchup('background');
|
|
511
|
+
if (this.halted || this.disposed || !this.isHidden() || this.socket !== null)
|
|
512
|
+
return;
|
|
513
|
+
if (ok && !this.hiddenSocketFailed) {
|
|
514
|
+
// Desktop hidden sockets usually live; one attempt per tick, none after
|
|
515
|
+
// the first hidden failure in this spell (phones cut them in seconds).
|
|
516
|
+
this.connectNow();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
this.enterDormant();
|
|
520
|
+
}
|
|
521
|
+
unsettledPending() {
|
|
522
|
+
return [...this.pending.values()].filter((entry) => !this.settledPending.has(entry.event.eventGuid));
|
|
523
|
+
}
|
|
524
|
+
async postPendingOverHttp() {
|
|
525
|
+
const postEvents = this.options.postEvents;
|
|
526
|
+
if (!postEvents)
|
|
527
|
+
return;
|
|
528
|
+
const events = this.unsettledPending()
|
|
529
|
+
.filter((entry) => !this.httpApplied.has(entry.event.eventGuid))
|
|
530
|
+
.map((entry) => entry.event);
|
|
531
|
+
if (events.length === 0)
|
|
532
|
+
return;
|
|
533
|
+
try {
|
|
534
|
+
const { outcomes } = await this.withTimeout((signal) => postEvents({ clientGuid: this.options.clientGuid, events }, signal));
|
|
535
|
+
if (this.stopBeforeStoreMutation())
|
|
536
|
+
return;
|
|
537
|
+
for (const outcome of outcomes) {
|
|
538
|
+
if (outcome.status === 'rejected') {
|
|
539
|
+
this.handleRejection({
|
|
540
|
+
type: 'rejection',
|
|
541
|
+
eventGuid: outcome.eventGuid,
|
|
542
|
+
error: outcome.error ?? { code: 'rejected', message: 'rejected' },
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
// Applied (or a duplicate): the catch-up's echo confirms it.
|
|
547
|
+
this.httpApplied.add(outcome.eventGuid);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
// Offline or timed out: the next tick, or the socket on return, retries.
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/** The background/return catch-up (review S5): one slot, a return aborts
|
|
556
|
+
* a background fetch in flight, both give up after 15 s. */
|
|
557
|
+
async runCatchup(kind) {
|
|
558
|
+
if (this.halted || this.disposed)
|
|
559
|
+
return false;
|
|
560
|
+
if (this.catchupSlot !== null) {
|
|
561
|
+
if (kind === 'return' && this.catchupSlot.kind === 'background')
|
|
562
|
+
this.catchupSlot.controller.abort();
|
|
563
|
+
else
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
const controller = new AbortController();
|
|
567
|
+
const slot = { kind, controller };
|
|
568
|
+
this.catchupSlot = slot;
|
|
569
|
+
this.abortControllers.add(controller);
|
|
570
|
+
const timeout = this.options.runtime.setTimeout(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
|
|
571
|
+
let ok = false;
|
|
572
|
+
try {
|
|
573
|
+
const result = await this.options.fetchCatchup(this.options.store.currentSeq(), controller.signal);
|
|
574
|
+
if (controller.signal.aborted || this.stopBeforeStoreMutation())
|
|
575
|
+
return false;
|
|
576
|
+
ok = true;
|
|
577
|
+
this.lastContactAt = this.now();
|
|
578
|
+
if (result.buildId)
|
|
579
|
+
this.options.onBuildObserved?.(result.buildId);
|
|
580
|
+
else if (kind === 'background')
|
|
581
|
+
this.options.onBuildChanged?.();
|
|
582
|
+
if (result.reload) {
|
|
583
|
+
this.requestReload('server');
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
for (const event of result.events)
|
|
587
|
+
this.handleServerEvent(event);
|
|
588
|
+
}
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
finally {
|
|
595
|
+
this.options.runtime.clearTimeout(timeout);
|
|
596
|
+
this.abortControllers.delete(controller);
|
|
597
|
+
if (this.catchupSlot === slot)
|
|
598
|
+
this.catchupSlot = null;
|
|
599
|
+
if (kind === 'background') {
|
|
600
|
+
if (ok)
|
|
601
|
+
this.counters.backgroundCatchups++;
|
|
602
|
+
else
|
|
603
|
+
this.counters.backgroundCatchupFailures++;
|
|
604
|
+
}
|
|
605
|
+
if (kind === 'return' && this.returnWindow !== null) {
|
|
606
|
+
this.returnWindow.catchupDone = true;
|
|
607
|
+
this.returnWindow.catchupOk = ok;
|
|
608
|
+
this.maybeFinishReturnWindow();
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
async withTimeout(operation) {
|
|
613
|
+
const controller = new AbortController();
|
|
614
|
+
this.abortControllers.add(controller);
|
|
615
|
+
const timeout = this.options.runtime.setTimeout(() => controller.abort(), BACKGROUND_FETCH_TIMEOUT_MS);
|
|
616
|
+
try {
|
|
617
|
+
return await operation(controller.signal);
|
|
618
|
+
}
|
|
619
|
+
finally {
|
|
620
|
+
this.options.runtime.clearTimeout(timeout);
|
|
621
|
+
this.abortControllers.delete(controller);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
openReturnWindow(hiddenMs, spell) {
|
|
625
|
+
this.finishReturnWindow();
|
|
626
|
+
const now = this.now();
|
|
627
|
+
this.returnWindow = {
|
|
628
|
+
visibleAt: now,
|
|
629
|
+
hiddenMs,
|
|
630
|
+
socketOpenAtReturn: this.socket?.readyState === SOCKET_OPEN,
|
|
631
|
+
sinceLastContactMs: this.lastContactAt === null ? null : Math.max(0, now - this.lastContactAt),
|
|
632
|
+
spell: SyncClient.delta(spell, this.snapshot()),
|
|
633
|
+
establishedAt: null,
|
|
634
|
+
catchupDone: false,
|
|
635
|
+
catchupOk: false,
|
|
636
|
+
eventsOnReturn: 0,
|
|
637
|
+
resyncOnReturn: 0,
|
|
638
|
+
timer: this.options.runtime.setTimeout(() => this.finishReturnWindow(), RETURN_REPORT_TIMEOUT_MS),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
maybeFinishReturnWindow() {
|
|
642
|
+
const window = this.returnWindow;
|
|
643
|
+
if (window !== null && window.establishedAt !== null && window.catchupDone)
|
|
644
|
+
this.finishReturnWindow();
|
|
645
|
+
}
|
|
646
|
+
/** One INFO per hidden spell: what the user saw change on return. */
|
|
647
|
+
finishReturnWindow() {
|
|
648
|
+
const window = this.returnWindow;
|
|
649
|
+
if (window === null)
|
|
650
|
+
return;
|
|
651
|
+
this.returnWindow = null;
|
|
652
|
+
this.options.runtime.clearTimeout(window.timer);
|
|
653
|
+
this.options.logger.info('page returned to foreground', {
|
|
654
|
+
hiddenMs: window.hiddenMs,
|
|
655
|
+
socketOpenAtReturn: window.socketOpenAtReturn,
|
|
656
|
+
reconnectMs: window.establishedAt === null ? null : Math.max(0, window.establishedAt - window.visibleAt),
|
|
657
|
+
catchupOk: window.catchupOk,
|
|
658
|
+
eventsOnReturn: window.eventsOnReturn,
|
|
659
|
+
resyncOnReturn: window.resyncOnReturn,
|
|
660
|
+
sinceLastContactMs: window.sinceLastContactMs,
|
|
661
|
+
backgroundCatchups: window.spell.backgroundCatchups,
|
|
662
|
+
backgroundCatchupFailures: window.spell.backgroundCatchupFailures,
|
|
663
|
+
backgroundSocketAttempts: window.spell.backgroundSocketAttempts,
|
|
664
|
+
watchdogKills: window.spell.watchdogKills,
|
|
665
|
+
freezes: window.spell.freezes,
|
|
666
|
+
resumes: window.spell.resumes,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
301
669
|
connectNow() {
|
|
302
670
|
if (this.reconnectTimer !== null)
|
|
303
671
|
this.options.runtime.clearTimeout(this.reconnectTimer);
|
|
@@ -353,13 +721,23 @@ export class SyncClient {
|
|
|
353
721
|
this.probeInFlight = false;
|
|
354
722
|
}
|
|
355
723
|
}
|
|
356
|
-
startOutage(closeCode) {
|
|
724
|
+
startOutage(closeCode, wasClean = false) {
|
|
357
725
|
if (this.outageStartedAt !== null)
|
|
358
726
|
return;
|
|
359
727
|
this.outageStartedAt = this.options.runtime.now();
|
|
360
728
|
this.outageEscalated = false;
|
|
729
|
+
this.outageCloseCode = closeCode;
|
|
730
|
+
this.outageInfo = { closeCode, wasClean, visibleAtDrop: !this.isHidden(), counters: this.snapshot() };
|
|
731
|
+
this.armEscalation();
|
|
732
|
+
}
|
|
733
|
+
/** ERROR only after OUTAGE_ESCALATE_MS of visible, on-time outage; re-armed
|
|
734
|
+
* on each return so an outage that began hidden still escalates (S3). */
|
|
735
|
+
armEscalation() {
|
|
736
|
+
if (this.escalateTimer !== null)
|
|
737
|
+
this.options.runtime.clearTimeout(this.escalateTimer);
|
|
361
738
|
this.escalateArmedAt = this.options.runtime.now();
|
|
362
739
|
this.escalateTimer = this.options.runtime.setTimeout(() => {
|
|
740
|
+
this.escalateTimer = null;
|
|
363
741
|
const late = this.options.runtime.now() - this.escalateArmedAt - OUTAGE_ESCALATE_MS;
|
|
364
742
|
if (this.options.runtime.visibility() === 'hidden' || late > 5000)
|
|
365
743
|
return;
|
|
@@ -367,7 +745,7 @@ export class SyncClient {
|
|
|
367
745
|
this.outageEscalated = true;
|
|
368
746
|
this.options.logger.error('sync outage ongoing', {
|
|
369
747
|
durationMs: this.options.runtime.now() - this.outageStartedAt,
|
|
370
|
-
closeCode,
|
|
748
|
+
closeCode: this.outageCloseCode,
|
|
371
749
|
attempts: this.reconnectAttempts,
|
|
372
750
|
});
|
|
373
751
|
}
|
|
@@ -397,6 +775,9 @@ export class SyncClient {
|
|
|
397
775
|
this.handleSocketCatchup(message);
|
|
398
776
|
return;
|
|
399
777
|
case 'reload':
|
|
778
|
+
if (this.connectReplyTimer !== null)
|
|
779
|
+
this.options.runtime.clearTimeout(this.connectReplyTimer);
|
|
780
|
+
this.connectReplyTimer = null;
|
|
400
781
|
this.requestReload('server');
|
|
401
782
|
return;
|
|
402
783
|
case 'event':
|
|
@@ -408,6 +789,9 @@ export class SyncClient {
|
|
|
408
789
|
}
|
|
409
790
|
}
|
|
410
791
|
handleSocketCatchup(message) {
|
|
792
|
+
if (this.connectReplyTimer !== null)
|
|
793
|
+
this.options.runtime.clearTimeout(this.connectReplyTimer);
|
|
794
|
+
this.connectReplyTimer = null;
|
|
411
795
|
if (message.buildId)
|
|
412
796
|
this.options.onBuildObserved?.(message.buildId);
|
|
413
797
|
let complete = true;
|
|
@@ -428,8 +812,12 @@ export class SyncClient {
|
|
|
428
812
|
return false;
|
|
429
813
|
const currentSeq = this.options.store.currentSeq();
|
|
430
814
|
if (event.seq <= currentSeq) {
|
|
431
|
-
|
|
815
|
+
// A second echo of an already-confirmed edit (socket and HTTP catch-up
|
|
816
|
+
// both deliver it on return) must not upgrade the settlement to a full
|
|
817
|
+
// reconcile (review S4).
|
|
818
|
+
if (this.pending.has(event.eventGuid) && !this.confirmedPending.has(event.eventGuid)) {
|
|
432
819
|
this.confirmPending(event.eventGuid, true);
|
|
820
|
+
}
|
|
433
821
|
return true;
|
|
434
822
|
}
|
|
435
823
|
if (event.seq > currentSeq + 1) {
|
|
@@ -452,6 +840,8 @@ export class SyncClient {
|
|
|
452
840
|
return false;
|
|
453
841
|
}
|
|
454
842
|
this.options.store.applyServerEvent(event);
|
|
843
|
+
if (this.returnWindow !== null && !this.pending.has(event.eventGuid))
|
|
844
|
+
this.returnWindow.eventsOnReturn++;
|
|
455
845
|
if (this.stopBeforeStoreMutation())
|
|
456
846
|
return false;
|
|
457
847
|
this.options.store.setCurrentSeq(event.seq);
|
|
@@ -623,6 +1013,8 @@ export class SyncClient {
|
|
|
623
1013
|
return true;
|
|
624
1014
|
}
|
|
625
1015
|
this.options.store.installBootstrap(bootstrap);
|
|
1016
|
+
if (this.returnWindow !== null)
|
|
1017
|
+
this.returnWindow.resyncOnReturn++;
|
|
626
1018
|
for (const [eventGuid, entry] of this.pending) {
|
|
627
1019
|
if (this.settledPending.has(eventGuid))
|
|
628
1020
|
continue;
|
|
@@ -637,7 +1029,24 @@ export class SyncClient {
|
|
|
637
1029
|
release();
|
|
638
1030
|
}
|
|
639
1031
|
}
|
|
1032
|
+
armFailureTimer(eventGuid) {
|
|
1033
|
+
this.clearFailureTimer(eventGuid);
|
|
1034
|
+
this.failureTimers.set(eventGuid, this.options.runtime.setTimeout(() => this.failUnconfirmed(eventGuid), FAILURE_TIMEOUT_MS));
|
|
1035
|
+
}
|
|
1036
|
+
/** On return: a verdict needs 60 s of visible time (review B2). */
|
|
1037
|
+
rearmFailureTimers() {
|
|
1038
|
+
for (const [eventGuid, entry] of this.pending) {
|
|
1039
|
+
if (entry.recovered || this.settledPending.has(eventGuid) || this.unresolvedFailures.has(eventGuid))
|
|
1040
|
+
continue;
|
|
1041
|
+
this.armFailureTimer(eventGuid);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
640
1044
|
failUnconfirmed(eventGuid) {
|
|
1045
|
+
this.failureTimers.delete(eventGuid);
|
|
1046
|
+
// Hidden: no verdict and no re-arm (that would wake the page every
|
|
1047
|
+
// minute); the return re-arms a full 60 s.
|
|
1048
|
+
if (this.isHidden())
|
|
1049
|
+
return;
|
|
641
1050
|
const entry = this.pending.get(eventGuid);
|
|
642
1051
|
if (!entry || entry.recovered)
|
|
643
1052
|
return;
|
|
@@ -649,19 +1058,43 @@ export class SyncClient {
|
|
|
649
1058
|
}
|
|
650
1059
|
onSyncEstablished() {
|
|
651
1060
|
if (this.outageStartedAt !== null) {
|
|
652
|
-
const durationMs = this.options.runtime.now() - this.outageStartedAt;
|
|
1061
|
+
const durationMs = Math.max(0, this.options.runtime.now() - this.outageStartedAt);
|
|
1062
|
+
// Failures are only counted (and the ladder only runs) while visible,
|
|
1063
|
+
// so `attempts` is what the user could have waited through.
|
|
653
1064
|
const attempts = this.reconnectAttempts;
|
|
1065
|
+
const info = this.outageInfo;
|
|
1066
|
+
const delta = info ? SyncClient.delta(info.counters, this.snapshot()) : null;
|
|
1067
|
+
const fields = {
|
|
1068
|
+
durationMs,
|
|
1069
|
+
attempts,
|
|
1070
|
+
closeCode: info?.closeCode,
|
|
1071
|
+
wasClean: info?.wasClean,
|
|
1072
|
+
visibleAtDrop: info?.visibleAtDrop,
|
|
1073
|
+
hiddenMs: delta?.hiddenMs,
|
|
1074
|
+
watchdogKills: delta?.watchdogKills,
|
|
1075
|
+
backgroundCatchups: delta?.backgroundCatchups,
|
|
1076
|
+
freezes: delta?.freezes,
|
|
1077
|
+
resumes: delta?.resumes,
|
|
1078
|
+
};
|
|
654
1079
|
if (this.outageEscalated) {
|
|
655
|
-
this.options.logger.info('sync outage recovered',
|
|
1080
|
+
this.options.logger.info('sync outage recovered', fields);
|
|
656
1081
|
}
|
|
657
1082
|
else if (attempts >= 2) {
|
|
658
|
-
this.options.logger.warn('sync outage recovered',
|
|
1083
|
+
this.options.logger.warn('sync outage recovered', fields);
|
|
659
1084
|
}
|
|
660
1085
|
else if (attempts === 1) {
|
|
661
|
-
this.options.logger.info('ws reconnected first try',
|
|
1086
|
+
this.options.logger.info('ws reconnected first try', fields);
|
|
1087
|
+
}
|
|
1088
|
+
else {
|
|
1089
|
+
this.options.logger.info('background sync outage recovered', fields);
|
|
662
1090
|
}
|
|
663
1091
|
this.outageStartedAt = null;
|
|
664
1092
|
this.outageEscalated = false;
|
|
1093
|
+
this.outageInfo = null;
|
|
1094
|
+
}
|
|
1095
|
+
if (this.returnWindow !== null && this.returnWindow.establishedAt === null) {
|
|
1096
|
+
this.returnWindow.establishedAt = this.now();
|
|
1097
|
+
this.maybeFinishReturnWindow();
|
|
665
1098
|
}
|
|
666
1099
|
if (this.escalateTimer !== null)
|
|
667
1100
|
this.options.runtime.clearTimeout(this.escalateTimer);
|
|
@@ -882,6 +1315,9 @@ export class SyncClient {
|
|
|
882
1315
|
this.publish({ kind: 'expired', pending: this.pendingCount() });
|
|
883
1316
|
}
|
|
884
1317
|
clearConnectionTimers() {
|
|
1318
|
+
if (this.connectReplyTimer !== null)
|
|
1319
|
+
this.options.runtime.clearTimeout(this.connectReplyTimer);
|
|
1320
|
+
this.connectReplyTimer = null;
|
|
885
1321
|
if (this.pingTimer !== null)
|
|
886
1322
|
this.options.runtime.clearInterval(this.pingTimer);
|
|
887
1323
|
if (this.watchdogTimer !== null)
|
|
@@ -904,6 +1340,10 @@ export class SyncClient {
|
|
|
904
1340
|
this.options.runtime.clearTimeout(this.resyncBackoffTimer);
|
|
905
1341
|
for (const timer of this.failureTimers.values())
|
|
906
1342
|
this.options.runtime.clearTimeout(timer);
|
|
1343
|
+
this.cancelBackgroundTick();
|
|
1344
|
+
if (this.returnWindow !== null)
|
|
1345
|
+
this.options.runtime.clearTimeout(this.returnWindow.timer);
|
|
1346
|
+
this.returnWindow = null;
|
|
907
1347
|
this.reconnectTimer = null;
|
|
908
1348
|
this.countdownTimer = null;
|
|
909
1349
|
this.escalateTimer = null;
|
package/package.json
CHANGED