@steve31415/baselib 3.25.0 → 3.26.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.
|
@@ -145,6 +145,7 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
145
145
|
private halted;
|
|
146
146
|
private refetchInFlight;
|
|
147
147
|
private probeInFlight;
|
|
148
|
+
private probeRequested;
|
|
148
149
|
private resyncTimes;
|
|
149
150
|
/** Forward-gap events delivered while a resync is reconciling; drained
|
|
150
151
|
* after the bootstrap installs. Non-null exactly while reconciling. */
|
|
@@ -200,6 +201,7 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
200
201
|
private onBecameVisible;
|
|
201
202
|
private onOnline;
|
|
202
203
|
private wakeConnection;
|
|
204
|
+
private logConnectTimeout;
|
|
203
205
|
/** Drops the current socket, if any, without treating it as an outage, and
|
|
204
206
|
* connects at once. */
|
|
205
207
|
private replaceSocket;
|
|
@@ -226,6 +228,12 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
226
228
|
* (once per return); anything else takes the ladder. */
|
|
227
229
|
private takeQuickRetry;
|
|
228
230
|
private scheduleReconnect;
|
|
231
|
+
/** One HTTP round trip while the visible socket is down: delivers pending
|
|
232
|
+
* events (`postEvents`), then catches up — which applies their echoes, so
|
|
233
|
+
* the writes confirm — and doubles as the session probe (an expired
|
|
234
|
+
* session halts through the auth state). A WebSocket handshake can be
|
|
235
|
+
* stuck for minutes behind another one in the browser's per-IP queue while
|
|
236
|
+
* HTTP works at once (notes, 2026-10-01), so writes must not wait for it. */
|
|
229
237
|
private probeSession;
|
|
230
238
|
private startOutage;
|
|
231
239
|
/** ERROR only after OUTAGE_ESCALATE_MS of visible, on-time outage; re-armed
|
|
@@ -27,6 +27,14 @@ const RETURN_REPORT_TIMEOUT_MS = 15_000;
|
|
|
27
27
|
* without reaching the server, while HTTP works at once; the ladder's 1 s
|
|
28
28
|
* step then made every such return take 1.5-2 s (reader, 2026-09-26). */
|
|
29
29
|
const RETURN_QUICK_RETRY_MS = 250;
|
|
30
|
+
/** A handshake that takes at least this long to open, or to fail on its own,
|
|
31
|
+
* is logged (INFO): it either sat in the browser's per-IP handshake queue or
|
|
32
|
+
* stalled itself. Chrome runs one WebSocket handshake per server IP:port at a
|
|
33
|
+
* time, across every tab and hostname; all fleet apps share the load
|
|
34
|
+
* balancer's IP, so one stalled handshake holds every app's sockets until it
|
|
35
|
+
* resolves (notes, 2026-10-01: four minutes). Fast failures (offline) are
|
|
36
|
+
* not logged. */
|
|
37
|
+
const SLOW_HANDSHAKE_MS = 3_000;
|
|
30
38
|
const SOCKET_CONNECTING = 0;
|
|
31
39
|
const SOCKET_OPEN = 1;
|
|
32
40
|
class LifecycleAbort {
|
|
@@ -71,6 +79,7 @@ export class SyncClient {
|
|
|
71
79
|
halted = false;
|
|
72
80
|
refetchInFlight = false;
|
|
73
81
|
probeInFlight = false;
|
|
82
|
+
probeRequested = false;
|
|
74
83
|
resyncTimes = [];
|
|
75
84
|
/** Forward-gap events delivered while a resync is reconciling; drained
|
|
76
85
|
* after the bootstrap installs. Non-null exactly while reconciling. */
|
|
@@ -105,6 +114,7 @@ export class SyncClient {
|
|
|
105
114
|
backgroundCatchups: 0,
|
|
106
115
|
backgroundCatchupFailures: 0,
|
|
107
116
|
backgroundSocketAttempts: 0,
|
|
117
|
+
httpPostedEvents: 0,
|
|
108
118
|
};
|
|
109
119
|
constructor(options) {
|
|
110
120
|
this.options = options;
|
|
@@ -222,6 +232,11 @@ export class SyncClient {
|
|
|
222
232
|
this.options.store.applyOptimistic(event);
|
|
223
233
|
this.armFailureTimer(event.eventGuid);
|
|
224
234
|
this.trySend(entry);
|
|
235
|
+
// A visible page in an outage delivers over HTTP at once rather than
|
|
236
|
+
// waiting for a handshake that may be stuck behind another one.
|
|
237
|
+
if (!this.isHidden() && this.outageStartedAt !== null && this.socket?.readyState !== SOCKET_OPEN) {
|
|
238
|
+
void this.probeSession();
|
|
239
|
+
}
|
|
225
240
|
this.updatePendingState();
|
|
226
241
|
this.options.onSafetyChange?.();
|
|
227
242
|
}
|
|
@@ -301,6 +316,7 @@ export class SyncClient {
|
|
|
301
316
|
backgroundCatchups: now.backgroundCatchups - since.backgroundCatchups,
|
|
302
317
|
backgroundCatchupFailures: now.backgroundCatchupFailures - since.backgroundCatchupFailures,
|
|
303
318
|
backgroundSocketAttempts: now.backgroundSocketAttempts - since.backgroundSocketAttempts,
|
|
319
|
+
httpPostedEvents: now.httpPostedEvents - since.httpPostedEvents,
|
|
304
320
|
};
|
|
305
321
|
}
|
|
306
322
|
onBecameHidden() {
|
|
@@ -379,12 +395,25 @@ export class SyncClient {
|
|
|
379
395
|
// closing is gone too. Waiting on either routes the returning user
|
|
380
396
|
// through the watchdog's close and a full backoff step (16-30 s of
|
|
381
397
|
// "Reconnecting…" observed on reader, 2026-09-24).
|
|
382
|
-
if (socket?.readyState === SOCKET_CONNECTING
|
|
383
|
-
this.options.runtime.now() - this.socketCreatedAt
|
|
384
|
-
|
|
398
|
+
if (socket?.readyState === SOCKET_CONNECTING) {
|
|
399
|
+
const socketAgeMs = this.options.runtime.now() - this.socketCreatedAt;
|
|
400
|
+
if (socketAgeMs < CONNECT_WATCHDOG_MS)
|
|
401
|
+
return;
|
|
402
|
+
// Its watchdog never ran: a throttled or frozen hidden page, the case
|
|
403
|
+
// that can hold the browser's handshake queue past 10 s.
|
|
404
|
+
this.logConnectTimeout(socketAgeMs, { replacedOnReturn: true });
|
|
385
405
|
}
|
|
386
406
|
this.replaceSocket();
|
|
387
407
|
}
|
|
408
|
+
logConnectTimeout(socketAgeMs, extra = {}) {
|
|
409
|
+
this.options.logger.info('sync connect timeout', {
|
|
410
|
+
socketAgeMs,
|
|
411
|
+
lateMs: Math.max(0, socketAgeMs - CONNECT_WATCHDOG_MS),
|
|
412
|
+
createdHidden: this.socketCreatedHidden,
|
|
413
|
+
hidden: this.isHidden(),
|
|
414
|
+
...extra,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
388
417
|
/** Drops the current socket, if any, without treating it as an outage, and
|
|
389
418
|
* connects at once. */
|
|
390
419
|
replaceSocket() {
|
|
@@ -422,9 +451,12 @@ export class SyncClient {
|
|
|
422
451
|
this.socketOpenedAt = null;
|
|
423
452
|
if (this.socketCreatedHidden)
|
|
424
453
|
this.counters.backgroundSocketAttempts++;
|
|
454
|
+
let killedByWatchdog = false;
|
|
425
455
|
this.watchdogTimer = this.options.runtime.setTimeout(() => {
|
|
426
456
|
if (socket.readyState !== SOCKET_OPEN) {
|
|
427
457
|
this.counters.watchdogKills++;
|
|
458
|
+
killedByWatchdog = true;
|
|
459
|
+
this.logConnectTimeout(this.options.runtime.now() - this.socketCreatedAt);
|
|
428
460
|
socket.close();
|
|
429
461
|
}
|
|
430
462
|
}, CONNECT_WATCHDOG_MS);
|
|
@@ -437,6 +469,14 @@ export class SyncClient {
|
|
|
437
469
|
this.options.runtime.clearTimeout(this.watchdogTimer);
|
|
438
470
|
this.watchdogTimer = null;
|
|
439
471
|
this.socketOpenedAt = this.now();
|
|
472
|
+
const openMs = this.socketOpenedAt - this.socketCreatedAt;
|
|
473
|
+
if (openMs >= SLOW_HANDSHAKE_MS) {
|
|
474
|
+
this.options.logger.info('sync connect slow', {
|
|
475
|
+
openMs,
|
|
476
|
+
createdHidden: this.socketCreatedHidden,
|
|
477
|
+
hidden: this.isHidden(),
|
|
478
|
+
});
|
|
479
|
+
}
|
|
440
480
|
this.cancelBackgroundTick();
|
|
441
481
|
this.sendConnect();
|
|
442
482
|
this.pingTimer = this.options.runtime.setInterval(() => this.sendRaw({ type: 'ping' }), PING_INTERVAL_MS);
|
|
@@ -462,6 +502,15 @@ export class SyncClient {
|
|
|
462
502
|
this.socket = null;
|
|
463
503
|
if (this.halted || this.disposed)
|
|
464
504
|
return;
|
|
505
|
+
const socketAgeMs = this.now() - this.socketCreatedAt;
|
|
506
|
+
if (this.socketOpenedAt === null && !killedByWatchdog && socketAgeMs >= SLOW_HANDSHAKE_MS) {
|
|
507
|
+
this.options.logger.info('sync connect failed', {
|
|
508
|
+
socketAgeMs,
|
|
509
|
+
closeCode: event.code,
|
|
510
|
+
createdHidden: this.socketCreatedHidden,
|
|
511
|
+
hidden: this.isHidden(),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
465
514
|
if (event.code === CLOSE_NO_USER) {
|
|
466
515
|
this.options.authState.markBlocked();
|
|
467
516
|
this.halt();
|
|
@@ -550,6 +599,8 @@ export class SyncClient {
|
|
|
550
599
|
if (this.stopBeforeStoreMutation())
|
|
551
600
|
return;
|
|
552
601
|
for (const outcome of outcomes) {
|
|
602
|
+
if (outcome.status !== 'rejected')
|
|
603
|
+
this.counters.httpPostedEvents++;
|
|
553
604
|
if (outcome.status === 'rejected') {
|
|
554
605
|
this.handleRejection({
|
|
555
606
|
type: 'rejection',
|
|
@@ -729,8 +780,9 @@ export class SyncClient {
|
|
|
729
780
|
const delay = Math.ceil(delayMs / 1000);
|
|
730
781
|
this.reconnectAttempts++;
|
|
731
782
|
this.totalAttempts++;
|
|
732
|
-
if (this.reconnectAttempts >= PROBE_AFTER_ATTEMPTS)
|
|
783
|
+
if (this.reconnectAttempts >= PROBE_AFTER_ATTEMPTS || this.unsettledPending().length > 0) {
|
|
733
784
|
void this.probeSession();
|
|
785
|
+
}
|
|
734
786
|
let remaining = delay;
|
|
735
787
|
this.publish({ kind: 'reconnecting', pending: this.pendingCount(), inSeconds: remaining });
|
|
736
788
|
this.countdownTimer = this.options.runtime.setInterval(() => {
|
|
@@ -747,11 +799,25 @@ export class SyncClient {
|
|
|
747
799
|
this.connect();
|
|
748
800
|
}, delayMs);
|
|
749
801
|
}
|
|
802
|
+
/** One HTTP round trip while the visible socket is down: delivers pending
|
|
803
|
+
* events (`postEvents`), then catches up — which applies their echoes, so
|
|
804
|
+
* the writes confirm — and doubles as the session probe (an expired
|
|
805
|
+
* session halts through the auth state). A WebSocket handshake can be
|
|
806
|
+
* stuck for minutes behind another one in the browser's per-IP queue while
|
|
807
|
+
* HTTP works at once (notes, 2026-10-01), so writes must not wait for it. */
|
|
750
808
|
async probeSession() {
|
|
751
|
-
if (this.halted || this.disposed
|
|
809
|
+
if (this.halted || this.disposed)
|
|
752
810
|
return;
|
|
811
|
+
if (this.probeInFlight) {
|
|
812
|
+
this.probeRequested = true;
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
753
815
|
this.probeInFlight = true;
|
|
816
|
+
this.probeRequested = false;
|
|
754
817
|
try {
|
|
818
|
+
await this.postPendingOverHttp();
|
|
819
|
+
if (this.stopBeforeStoreMutation())
|
|
820
|
+
return;
|
|
755
821
|
const result = await this.abortable((signal) => this.options.fetchCatchup(this.options.store.currentSeq(), signal));
|
|
756
822
|
if (this.stopBeforeStoreMutation())
|
|
757
823
|
return;
|
|
@@ -771,6 +837,11 @@ export class SyncClient {
|
|
|
771
837
|
finally {
|
|
772
838
|
this.probeInFlight = false;
|
|
773
839
|
}
|
|
840
|
+
// Something was submitted while this probe was in flight; deliver it
|
|
841
|
+
// too, unless the socket came back in the meantime.
|
|
842
|
+
if (this.probeRequested && this.socket?.readyState !== SOCKET_OPEN && !this.isHidden()) {
|
|
843
|
+
void this.probeSession();
|
|
844
|
+
}
|
|
774
845
|
}
|
|
775
846
|
startOutage(closeCode, wasClean = false) {
|
|
776
847
|
if (this.outageStartedAt !== null)
|
|
@@ -1126,6 +1197,7 @@ export class SyncClient {
|
|
|
1126
1197
|
hiddenMs: delta?.hiddenMs,
|
|
1127
1198
|
watchdogKills: delta?.watchdogKills,
|
|
1128
1199
|
backgroundCatchups: delta?.backgroundCatchups,
|
|
1200
|
+
httpPostedEvents: delta?.httpPostedEvents,
|
|
1129
1201
|
freezes: delta?.freezes,
|
|
1130
1202
|
resumes: delta?.resumes,
|
|
1131
1203
|
};
|
package/package.json
CHANGED