@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.
- package/dist/sync-browser/client.d.ts +17 -0
- package/dist/sync-browser/client.js +115 -12
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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