@steve31415/baselib 3.10.0 → 3.12.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.
@@ -125,6 +125,11 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
125
125
  private refetchInFlight;
126
126
  private probeInFlight;
127
127
  private resyncTimes;
128
+ /** Forward-gap events delivered while a resync is reconciling; drained
129
+ * after the bootstrap installs. Non-null exactly while reconciling. */
130
+ private gapBuffer;
131
+ private resyncBackoffTimer;
132
+ private loopEscalated;
128
133
  private reconciliationTail;
129
134
  private initialization;
130
135
  private disposed;
@@ -169,6 +174,18 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
169
174
  private onSyncEstablished;
170
175
  private requestReload;
171
176
  private resyncFromServer;
177
+ /** Applies the events delivered while the bootstrap was in flight. On a
178
+ * single socket they are contiguous with the install point, so this is
179
+ * where a resync under a live stream converges; a hole (socket replaced
180
+ * mid-fetch, buffer overflow) schedules a fresh resync via the normal gap
181
+ * path instead. */
182
+ private drainAfterResync;
183
+ /** A stall episode ends only on proven convergence: a resync whose drain
184
+ * scheduled nothing further, or a complete socket catch-up. A successful
185
+ * reconcile alone is not convergence — in a chronic diverge-reconverge
186
+ * cycle every reconcile succeeds. */
187
+ private endResyncEpisode;
188
+ private scheduleResyncRetry;
172
189
  private sendConnect;
173
190
  private sendRaw;
174
191
  private trySend;
@@ -3,7 +3,16 @@ const PING_INTERVAL_MS = 30_000;
3
3
  const CONNECT_WATCHDOG_MS = 10_000;
4
4
  const DEFAULT_RECONNECT_BEFORE_MS = 55 * 60 * 1000;
5
5
  const FAILURE_TIMEOUT_MS = 60_000;
6
- const OUTAGE_ESCALATE_MS = 30_000;
6
+ // ERROR only after a sustained foreground outage. The fleet is single-user
7
+ // and triage runs daily, so detection latency is cheap; short client-network
8
+ // blips (Wi-Fi roam, VPN re-auth — observed 30-40 s) must not page as ERROR
9
+ // (Steve, 2026-09-11; raised from 30 s, which flagged those blips).
10
+ const OUTAGE_ESCALATE_MS = 120_000;
11
+ const RESYNC_LOOP_WINDOW_MS = 60_000;
12
+ const RESYNC_LOOP_MAX = 3;
13
+ const RESYNC_RETRY_MS = 60_000;
14
+ const GAP_BUFFER_MAX = 1_000;
15
+ const RECONCILE_MOVED_MAX_REFETCHES = 3;
7
16
  const CLOSE_NO_USER = 4403;
8
17
  const PROBE_AFTER_ATTEMPTS = 2;
9
18
  const SOCKET_OPEN = 1;
@@ -45,6 +54,11 @@ export class SyncClient {
45
54
  refetchInFlight = false;
46
55
  probeInFlight = false;
47
56
  resyncTimes = [];
57
+ /** Forward-gap events delivered while a resync is reconciling; drained
58
+ * after the bootstrap installs. Non-null exactly while reconciling. */
59
+ gapBuffer = null;
60
+ resyncBackoffTimer = null;
61
+ loopEscalated = false;
48
62
  reconciliationTail = Promise.resolve();
49
63
  initialization = null;
50
64
  disposed = false;
@@ -419,7 +433,22 @@ export class SyncClient {
419
433
  return true;
420
434
  }
421
435
  if (event.seq > currentSeq + 1) {
422
- this.requestReload('sequence-gap');
436
+ // An own-write echo proves the commit even when the gap prevents
437
+ // applying it: settle the pending (without a settlement reconcile —
438
+ // the resync below installs fresh state anyway) so the 60 s failure
439
+ // timer cannot report a committed write as failed.
440
+ if (this.pending.has(event.eventGuid))
441
+ this.confirmPending(event.eventGuid, false);
442
+ if (this.gapBuffer !== null) {
443
+ if (this.gapBuffer.length < GAP_BUFFER_MAX)
444
+ this.gapBuffer.push(event);
445
+ // On overflow, drop the newest: the kept prefix stays contiguous
446
+ // with the install point; the drain then ends at a hole and
447
+ // schedules a fresh resync.
448
+ }
449
+ else {
450
+ this.requestReload('sequence-gap');
451
+ }
423
452
  return false;
424
453
  }
425
454
  this.options.store.applyServerEvent(event);
@@ -575,15 +604,24 @@ export class SyncClient {
575
604
  });
576
605
  await preceding;
577
606
  try {
578
- while (true) {
607
+ for (let attempt = 0;; attempt++) {
579
608
  if (this.stopBeforeStoreMutation())
580
609
  return false;
581
610
  const startingSeq = this.options.store.currentSeq();
582
611
  const bootstrap = await this.abortable((signal) => this.options.fetchBootstrap(signal));
583
612
  if (this.stopBeforeStoreMutation())
584
613
  return false;
585
- if (this.options.store.currentSeq() !== startingSeq)
586
- continue;
614
+ if (this.options.store.currentSeq() !== startingSeq) {
615
+ // The store advanced mid-fetch: live contiguous events are
616
+ // applying, so the client is converged-and-advancing. Refetch a
617
+ // bounded number of times for a quiescent snapshot; past that,
618
+ // keep the live state — installing the older bootstrap would
619
+ // rewind it. Any residual divergence surfaces as a normal gap
620
+ // and a fresh, detector-counted resync.
621
+ if (attempt < RECONCILE_MOVED_MAX_REFETCHES)
622
+ continue;
623
+ return true;
624
+ }
587
625
  this.options.store.installBootstrap(bootstrap);
588
626
  for (const [eventGuid, entry] of this.pending) {
589
627
  if (this.settledPending.has(eventGuid))
@@ -637,9 +675,18 @@ export class SyncClient {
637
675
  this.trySend(entry);
638
676
  }
639
677
  }
678
+ // A complete catch-up IS convergence: end any stall episode and cancel
679
+ // its pending backoff retry.
680
+ this.endResyncEpisode();
640
681
  this.updateConnectedState();
641
682
  }
642
683
  requestReload(reason) {
684
+ // While a resync is reconciling or backing off, further triggers are
685
+ // absorbed: the in-flight/scheduled attempt already covers them, and
686
+ // per-event callbacks were the WARN/ERROR spam of the 2026-09-10
687
+ // incident. The callback fires once per resync attempt.
688
+ if (this.refetchInFlight || this.resyncBackoffTimer !== null)
689
+ return;
643
690
  this.options.onReloadRequired(reason);
644
691
  void this.resyncFromServer(reason === 'server' ? 'server requested reload' : 'sequence gap');
645
692
  }
@@ -647,23 +694,35 @@ export class SyncClient {
647
694
  if (this.refetchInFlight || this.halted || this.disposed)
648
695
  return;
649
696
  const now = this.options.runtime.now();
650
- this.resyncTimes = [...this.resyncTimes.filter((time) => now - time < 60_000), now];
651
- if (this.resyncTimes.length > 3) {
652
- this.options.logger.error('resync loop detected', { reason });
697
+ // `time <= now` drops entries stranded in the future by a backward
698
+ // clock jump, which would otherwise pin the detector tripped.
699
+ this.resyncTimes = [
700
+ ...this.resyncTimes.filter((time) => time <= now && now - time < RESYNC_LOOP_WINDOW_MS),
701
+ now,
702
+ ];
703
+ if (this.resyncTimes.length > RESYNC_LOOP_MAX) {
704
+ if (!this.loopEscalated) {
705
+ this.loopEscalated = true;
706
+ this.options.logger.error('resync loop detected', { reason });
707
+ }
653
708
  this.publish({
654
709
  kind: 'error',
655
710
  pending: this.pendingCount(),
656
- message: 'Repeated resyncs require a page reload',
711
+ message: 'Repeated resyncs; retrying automatically',
657
712
  });
713
+ this.scheduleResyncRetry();
658
714
  return;
659
715
  }
660
716
  this.refetchInFlight = true;
717
+ this.gapBuffer = [];
718
+ let reconciled = false;
661
719
  try {
662
720
  this.options.logger.info('resyncing from server', { reason });
663
- if (!(await this.reconcileStore()))
664
- return;
665
- for (const entry of this.pending.values())
666
- this.trySend(entry);
721
+ reconciled = await this.reconcileStore();
722
+ if (reconciled) {
723
+ for (const entry of this.pending.values())
724
+ this.trySend(entry);
725
+ }
667
726
  }
668
727
  catch (error) {
669
728
  if (!isLifecycleAbort(error) && !this.disposed && !this.halted) {
@@ -671,9 +730,54 @@ export class SyncClient {
671
730
  }
672
731
  }
673
732
  finally {
733
+ // Ordering matters: take the buffer and release the flag before the
734
+ // drain, so a drain-triggered nested resync owns `refetchInFlight`
735
+ // (and a fresh buffer) alone — this finally has nothing left to clear.
736
+ const buffered = this.gapBuffer ?? [];
737
+ this.gapBuffer = null;
674
738
  this.refetchInFlight = false;
739
+ if (reconciled)
740
+ this.drainAfterResync(buffered);
675
741
  }
676
742
  }
743
+ /** Applies the events delivered while the bootstrap was in flight. On a
744
+ * single socket they are contiguous with the install point, so this is
745
+ * where a resync under a live stream converges; a hole (socket replaced
746
+ * mid-fetch, buffer overflow) schedules a fresh resync via the normal gap
747
+ * path instead. */
748
+ drainAfterResync(buffered) {
749
+ for (const event of buffered) {
750
+ if (this.halted || this.disposed)
751
+ return;
752
+ if (!this.handleServerEvent(event))
753
+ return;
754
+ }
755
+ this.endResyncEpisode();
756
+ }
757
+ /** A stall episode ends only on proven convergence: a resync whose drain
758
+ * scheduled nothing further, or a complete socket catch-up. A successful
759
+ * reconcile alone is not convergence — in a chronic diverge-reconverge
760
+ * cycle every reconcile succeeds. */
761
+ endResyncEpisode() {
762
+ if (this.resyncBackoffTimer !== null) {
763
+ this.options.runtime.clearTimeout(this.resyncBackoffTimer);
764
+ this.resyncBackoffTimer = null;
765
+ }
766
+ if (!this.loopEscalated)
767
+ return;
768
+ this.loopEscalated = false;
769
+ this.updateConnectedState();
770
+ }
771
+ scheduleResyncRetry() {
772
+ if (this.resyncBackoffTimer !== null || this.halted || this.disposed)
773
+ return;
774
+ // A hidden tab's timer throttling may stretch this well past the nominal
775
+ // delay; that only postpones recovery, it never breaks it.
776
+ this.resyncBackoffTimer = this.options.runtime.setTimeout(() => {
777
+ this.resyncBackoffTimer = null;
778
+ void this.resyncFromServer('retry after resync backoff');
779
+ }, RESYNC_RETRY_MS);
780
+ }
677
781
  sendConnect() {
678
782
  this.sendRaw({
679
783
  type: 'connect',
@@ -796,11 +900,14 @@ export class SyncClient {
796
900
  this.options.runtime.clearInterval(this.countdownTimer);
797
901
  if (this.escalateTimer !== null)
798
902
  this.options.runtime.clearTimeout(this.escalateTimer);
903
+ if (this.resyncBackoffTimer !== null)
904
+ this.options.runtime.clearTimeout(this.resyncBackoffTimer);
799
905
  for (const timer of this.failureTimers.values())
800
906
  this.options.runtime.clearTimeout(timer);
801
907
  this.reconnectTimer = null;
802
908
  this.countdownTimer = null;
803
909
  this.escalateTimer = null;
910
+ this.resyncBackoffTimer = null;
804
911
  this.failureTimers.clear();
805
912
  }
806
913
  detachSocket(socket) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.10.0",
3
+ "version": "3.12.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",