@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.
- package/dist/sync-browser/client.d.ts +17 -0
- package/dist/sync-browser/client.js +120 -13
- 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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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