@steve31415/baselib 3.11.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;
@@ -8,6 +8,11 @@ const FAILURE_TIMEOUT_MS = 60_000;
8
8
  // blips (Wi-Fi roam, VPN re-auth — observed 30-40 s) must not page as ERROR
9
9
  // (Steve, 2026-09-11; raised from 30 s, which flagged those blips).
10
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;
11
16
  const CLOSE_NO_USER = 4403;
12
17
  const PROBE_AFTER_ATTEMPTS = 2;
13
18
  const SOCKET_OPEN = 1;
@@ -49,6 +54,11 @@ export class SyncClient {
49
54
  refetchInFlight = false;
50
55
  probeInFlight = false;
51
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;
52
62
  reconciliationTail = Promise.resolve();
53
63
  initialization = null;
54
64
  disposed = false;
@@ -423,7 +433,22 @@ export class SyncClient {
423
433
  return true;
424
434
  }
425
435
  if (event.seq > currentSeq + 1) {
426
- 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
+ }
427
452
  return false;
428
453
  }
429
454
  this.options.store.applyServerEvent(event);
@@ -579,15 +604,24 @@ export class SyncClient {
579
604
  });
580
605
  await preceding;
581
606
  try {
582
- while (true) {
607
+ for (let attempt = 0;; attempt++) {
583
608
  if (this.stopBeforeStoreMutation())
584
609
  return false;
585
610
  const startingSeq = this.options.store.currentSeq();
586
611
  const bootstrap = await this.abortable((signal) => this.options.fetchBootstrap(signal));
587
612
  if (this.stopBeforeStoreMutation())
588
613
  return false;
589
- if (this.options.store.currentSeq() !== startingSeq)
590
- 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
+ }
591
625
  this.options.store.installBootstrap(bootstrap);
592
626
  for (const [eventGuid, entry] of this.pending) {
593
627
  if (this.settledPending.has(eventGuid))
@@ -641,9 +675,18 @@ export class SyncClient {
641
675
  this.trySend(entry);
642
676
  }
643
677
  }
678
+ // A complete catch-up IS convergence: end any stall episode and cancel
679
+ // its pending backoff retry.
680
+ this.endResyncEpisode();
644
681
  this.updateConnectedState();
645
682
  }
646
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;
647
690
  this.options.onReloadRequired(reason);
648
691
  void this.resyncFromServer(reason === 'server' ? 'server requested reload' : 'sequence gap');
649
692
  }
@@ -651,23 +694,35 @@ export class SyncClient {
651
694
  if (this.refetchInFlight || this.halted || this.disposed)
652
695
  return;
653
696
  const now = this.options.runtime.now();
654
- this.resyncTimes = [...this.resyncTimes.filter((time) => now - time < 60_000), now];
655
- if (this.resyncTimes.length > 3) {
656
- 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
+ }
657
708
  this.publish({
658
709
  kind: 'error',
659
710
  pending: this.pendingCount(),
660
- message: 'Repeated resyncs require a page reload',
711
+ message: 'Repeated resyncs; retrying automatically',
661
712
  });
713
+ this.scheduleResyncRetry();
662
714
  return;
663
715
  }
664
716
  this.refetchInFlight = true;
717
+ this.gapBuffer = [];
718
+ let reconciled = false;
665
719
  try {
666
720
  this.options.logger.info('resyncing from server', { reason });
667
- if (!(await this.reconcileStore()))
668
- return;
669
- for (const entry of this.pending.values())
670
- this.trySend(entry);
721
+ reconciled = await this.reconcileStore();
722
+ if (reconciled) {
723
+ for (const entry of this.pending.values())
724
+ this.trySend(entry);
725
+ }
671
726
  }
672
727
  catch (error) {
673
728
  if (!isLifecycleAbort(error) && !this.disposed && !this.halted) {
@@ -675,9 +730,54 @@ export class SyncClient {
675
730
  }
676
731
  }
677
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;
678
738
  this.refetchInFlight = false;
739
+ if (reconciled)
740
+ this.drainAfterResync(buffered);
679
741
  }
680
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
+ }
681
781
  sendConnect() {
682
782
  this.sendRaw({
683
783
  type: 'connect',
@@ -800,11 +900,14 @@ export class SyncClient {
800
900
  this.options.runtime.clearInterval(this.countdownTimer);
801
901
  if (this.escalateTimer !== null)
802
902
  this.options.runtime.clearTimeout(this.escalateTimer);
903
+ if (this.resyncBackoffTimer !== null)
904
+ this.options.runtime.clearTimeout(this.resyncBackoffTimer);
803
905
  for (const timer of this.failureTimers.values())
804
906
  this.options.runtime.clearTimeout(timer);
805
907
  this.reconnectTimer = null;
806
908
  this.countdownTimer = null;
807
909
  this.escalateTimer = null;
910
+ this.resyncBackoffTimer = null;
808
911
  this.failureTimers.clear();
809
912
  }
810
913
  detachSocket(socket) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.11.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",