@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 < CONNECT_WATCHDOG_MS) {
384
- return;
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 || this.probeInFlight)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.25.0",
3
+ "version": "3.26.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",