@edryslabs/genericprovider 1.0.3 → 1.0.4

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/index.js CHANGED
@@ -81,16 +81,24 @@ function unwrapAndVerifyMessage(wrapped) {
81
81
  return message;
82
82
  }
83
83
  /**
84
- * Compute a simple hash of document state for desync detection.
85
- * Uses a fast non-cryptographic hash for performance.
84
+ * Compute a cheap hash of document state for desync detection.
86
85
  *
87
- * Hashes the state VECTOR, not encodeStateAsUpdate: the full update byte stream
88
- * is NOT canonical across CRDT-convergent replicas (client-block and tombstone
89
- * ordering differ per peer), so hashing it flags false divergence and triggers
90
- * an endless re-sync loop. The state vector (clientID -> clock) is serialized in
91
- * sorted clientID order by Yjs, so two convergent docs hash identically, while a
92
- * missed update still shows up as a differing clock which is exactly the
93
- * "did we fall behind?" signal this check exists to provide.
86
+ * Hashes the state VECTOR, not encodeStateAsUpdate, for two independent reasons:
87
+ *
88
+ * 1. CORRECTNESS: the full update byte stream is NOT canonical across
89
+ * CRDT-convergent replicas (client-block and tombstone ordering differ per
90
+ * peer), so hashing it flags false divergence and triggers an endless
91
+ * re-sync loop. The state vector (clientID -> clock) is serialized in sorted
92
+ * clientID order by Yjs, so two convergent docs hash identically, while a
93
+ * missed update still shows up as a differing clock — exactly the "did we
94
+ * fall behind?" signal this check exists to provide.
95
+ * 2. COST: O(number of distinct clients) instead of O(document content size),
96
+ * so this no longer re-serializes the entire document on every update.
97
+ *
98
+ * Two peers can only reach the same state vector by having applied the same set
99
+ * of operations, so real content divergence is still caught. (CRC32 already
100
+ * guards wire corruption, and sequence tracking guards reordering/loss — this
101
+ * hash is the last line of defense against logical divergence between peers.)
94
102
  */
95
103
  function computeDocHash(doc) {
96
104
  const state = Y.encodeStateVector(doc);
@@ -214,19 +222,41 @@ export class GenericProvider extends Observable {
214
222
  // BroadcastChannel state for cross-tab sync
215
223
  this._bcChannel = '';
216
224
  this._bcConnected = false;
217
- // Hash verification tracking for exponential backoff
218
- this._hashMismatchCount = 0;
219
- this._lastHashMismatchTime = 0;
225
+ // Unified resync-request coordinator. Previously hash-mismatch,
226
+ // corrupted-message, and gap-confirmed triggers each coalesced only
227
+ // against themselves (three separate pending-timer fields, three
228
+ // separate escalation counters), so under sustained wire corruption they
229
+ // could each independently burn through the shared _tryReserveSyncSlot()
230
+ // budget in the same window - a resync storm that grew combinatorially
231
+ // with peer count (see test/dummy/bench-corruption-storm.ts: at 10
232
+ // simulated peers, 5% per-link corruption drove message volume to ~11x
233
+ // the corruption-free baseline). Now there is exactly ONE pending timer
234
+ // and ONE shared escalation counter for all three triggers - only one
235
+ // resync is ever in flight at a time, and any trigger that fires while
236
+ // one is already pending is absorbed into it instead of scheduling its
237
+ // own. See _requestResync().
238
+ this._resyncAttemptCount = 0;
239
+ this._lastResyncAttemptTime = 0;
220
240
  // Rate limiting for sync requests
221
241
  this._syncRequestTimes = [];
222
- this._maxSyncRequestsPerWindow = 20; // max requests per 10 seconds
223
- this._syncRequestWindowMs = 10000; // 10 second window
242
+ // SyncStep2 reply suppression (NACK-suppression style): delay a reply to
243
+ // a SyncStep1 request briefly, and drop it if another peer's reply is
244
+ // overheard first - since every reply is broadcast to the whole room
245
+ // anyway, this avoids every peer answering the same request redundantly.
246
+ // Only engages when there's genuine redundancy (see _handleIncomingMessage's
247
+ // MESSAGE_SYNC and MESSAGE_SYNC_VERIFIED cases) - with 0-1 other known
248
+ // peers there's no "someone else" to rely on, so replies go out
249
+ // immediately as before.
250
+ this._pendingSyncReply = null;
224
251
  // Sequence numbers for causal ordering
225
252
  this._localSeqNum = 0; // Our sequence number counter
226
- this._remoteSeqNums = new Map(); // clientID -> last seen seqNum
227
- // Message integrity tracking
228
- this._corruptedMessageCount = 0; // Track rejected corrupted messages
229
- this._lastCorruptedMessageTime = 0;
253
+ // Per-sender sequence tracking for reordering-tolerant gap detection.
254
+ // Applying a Yjs update is always safe even for duplicates or out-of-order
255
+ // arrivals (Yjs updates are idempotent/commutative) this state exists
256
+ // only to detect genuine gaps (likely packet loss) without false
257
+ // positives from mere network reordering. See _trackRemoteSeq().
258
+ this._remoteSeqInfo = new Map();
259
+ this._gapCheckTimers = new Map();
230
260
  // Update batching/debouncing
231
261
  this._batchUpdates = 0; // milliseconds delay (0 = disabled)
232
262
  this._pendingUpdate = null;
@@ -250,12 +280,18 @@ export class GenericProvider extends Observable {
250
280
  options.appAwareness || new awarenessProtocol.Awareness(doc);
251
281
  this._syncInterval = options.syncInterval ?? 5000;
252
282
  this._verifyUpdates = options.verifyUpdates ?? true;
253
- this._batchUpdates = options.batchUpdates ?? 0;
283
+ this._batchUpdates =
284
+ options.batchUpdates ?? transport.preferredBatchMs ?? 0;
254
285
  this._disableBc = options.disableBc ?? false;
255
286
  this._awarenessInterval = options.awarenessInterval ?? 100;
256
287
  this._excludeOrigins = new Set(options.excludeOrigins ?? []);
257
288
  this._localId = options.localId;
258
289
  this._syncMode = options.syncMode ?? 'push-pull';
290
+ this._maxSyncRequestsPerWindow = options.maxSyncRequestsPerWindow ?? 20;
291
+ this._syncRequestWindowMs = options.syncRequestWindowMs ?? 10000;
292
+ this._syncReplySuppressionMs = options.syncReplySuppressionMs ?? 30;
293
+ this._gapGraceMs = options.gapGraceMs ?? 300;
294
+ this._seqWindowSize = options.seqWindowSize ?? 64;
259
295
  this._setupDocumentSync();
260
296
  this._setupAwarenessSync();
261
297
  }
@@ -317,16 +353,12 @@ export class GenericProvider extends Observable {
317
353
  this._broadcastAwareness([this.doc.clientID], AWARENESS_CHANNEL_APP);
318
354
  // Start periodic sync to handle packet loss
319
355
  // Just request sync without sending full state (avoid redundant broadcasts)
356
+ // _sendSyncStep1() already checks the shared rate limiter internally
357
+ // and silently drops the request if it's exceeded.
320
358
  if (this._syncInterval > 0) {
321
359
  this._syncIntervalId = setInterval(() => {
322
360
  if (this.transport.isConnected && !this._destroying) {
323
- // Check rate limit before syncing
324
- const now = Date.now();
325
- this._syncRequestTimes = this._syncRequestTimes.filter((t) => now - t < this._syncRequestWindowMs);
326
- if (this._syncRequestTimes.length < this._maxSyncRequestsPerWindow) {
327
- this._sendSyncStep1();
328
- }
329
- // If rate limited, skip this periodic sync - will try again next interval
361
+ this._sendSyncStep1();
330
362
  }
331
363
  }, this._syncInterval);
332
364
  }
@@ -349,9 +381,26 @@ export class GenericProvider extends Observable {
349
381
  clearInterval(this._syncIntervalId);
350
382
  this._syncIntervalId = undefined;
351
383
  }
352
- // Reset corruption tracking
353
- this._corruptedMessageCount = 0;
354
- this._lastCorruptedMessageTime = 0;
384
+ // Reset resync escalation tracking
385
+ this._resyncAttemptCount = 0;
386
+ this._lastResyncAttemptTime = 0;
387
+ // Cancel any pending unified resync - it would otherwise still fire
388
+ // syncNow() after disconnect/reconnect against a transport that may be
389
+ // in a completely different state by then.
390
+ if (this._pendingResyncTimeoutId !== undefined) {
391
+ clearTimeout(this._pendingResyncTimeoutId);
392
+ this._pendingResyncTimeoutId = undefined;
393
+ }
394
+ // Reset the sync rate-limit budget. Without this, a reconnect inherits
395
+ // whatever budget was left over from before the disconnect - and since
396
+ // syncNow()'s full-state push now shares this same limiter (see
397
+ // _tryReserveSyncSlot()), a rate-limited reconnect could silently skip
398
+ // the very push that delivers edits made while offline.
399
+ this._syncRequestTimes = [];
400
+ // Drop any pending suppressed sync reply - safe to simply discard (not
401
+ // flush/send like batched updates/awareness below), since a suppressed
402
+ // reply is by design redundant with whatever the room already has.
403
+ this._cancelPendingSyncReply();
355
404
  // Flush any pending batched updates before disconnecting
356
405
  if (this._batchTimeoutId !== undefined) {
357
406
  clearTimeout(this._batchTimeoutId);
@@ -408,6 +457,14 @@ export class GenericProvider extends Observable {
408
457
  clearInterval(this._syncIntervalId);
409
458
  this._syncIntervalId = undefined;
410
459
  }
460
+ // Stop any pending gap-check timers
461
+ for (const timer of this._gapCheckTimers.values()) {
462
+ clearTimeout(timer);
463
+ }
464
+ this._gapCheckTimers.clear();
465
+ // Drop any pending suppressed sync reply (disconnect() will also do
466
+ // this, but be explicit)
467
+ this._cancelPendingSyncReply();
411
468
  // Flush any pending batched updates before destroying
412
469
  if (this._batchTimeoutId !== undefined) {
413
470
  clearTimeout(this._batchTimeoutId);
@@ -475,15 +532,30 @@ export class GenericProvider extends Observable {
475
532
  console.warn('Cannot sync: transport not connected');
476
533
  return;
477
534
  }
478
- // Send our current document state to all peers
479
- // This ensures any changes made while offline are transmitted
480
- const update = Y.encodeStateAsUpdate(this.doc);
481
- if (update.length > 0) {
482
- this._sendUpdate(update);
535
+ // Push (full document state) and pull (SyncStep1 request) share a
536
+ // single rate-limit reservation. syncNow() is called from several
537
+ // triggers that can all fire in a short window when many peers are
538
+ // converging at once (hash-mismatch resyncs, gap-check confirmations,
539
+ // per-peer connect events on mesh transports) - without this gate the
540
+ // push above had NO limit at all, so each trigger broadcast the full
541
+ // document state to the whole room, and those broadcasts caused more
542
+ // reordering/mismatches elsewhere, causing more triggers. Measured in
543
+ // test/dummy/bench-user-scaling.ts: at 100 simulated users this drove
544
+ // message counts to 20-200x the theoretical linear cost. See
545
+ // docs/superpowers/specs/2026-07-26-dummy-benchmark-scaling-design.md.
546
+ if (this._tryReserveSyncSlot()) {
547
+ // Send our current document state to all peers
548
+ // This ensures any changes made while offline are transmitted
549
+ const update = Y.encodeStateAsUpdate(this.doc);
550
+ if (update.length > 0) {
551
+ this._sendUpdate(update);
552
+ }
553
+ // Send sync request to get updates from others
554
+ this._writeSyncStep1();
483
555
  }
484
- // Send sync request to get updates from others
485
- this._sendSyncStep1();
486
- // Broadcast current awareness state
556
+ // Broadcast current awareness state - independently throttled and much
557
+ // cheaper than a full document push, so it isn't gated by the sync
558
+ // rate limiter above even when the sync half is skipped.
487
559
  this._broadcastAwareness([this.doc.clientID]);
488
560
  this._broadcastAwareness([this.doc.clientID], AWARENESS_CHANNEL_APP);
489
561
  }
@@ -584,23 +656,12 @@ export class GenericProvider extends Observable {
584
656
  const message = unwrapAndVerifyMessage(data);
585
657
  if (message === null) {
586
658
  // Message is corrupted - reject it immediately
587
- this._corruptedMessageCount++;
588
- const now = Date.now();
589
- // Reset counter if it's been stable for 10 seconds
590
- if (now - this._lastCorruptedMessageTime > 10000) {
591
- this._corruptedMessageCount = 1;
592
- }
593
- this._lastCorruptedMessageTime = now;
594
- console.warn(`[GenericProvider] 💥 Corrupted message rejected (#${this._corruptedMessageCount}): CRC32 checksum mismatch. ` +
659
+ console.warn(`[GenericProvider] 💥 Corrupted message rejected: CRC32 checksum mismatch. ` +
595
660
  `This is expected if data corruption simulation is enabled.`);
596
- // Request re-sync to recover any lost data
597
- // Use exponential backoff: 100ms, 500ms, 2.5s, then cap at 5s
598
- const delay = Math.min(5000, 100 * Math.pow(5, Math.min(this._corruptedMessageCount - 1, 3)));
599
- setTimeout(() => {
600
- if (this.transport.isConnected && !this._destroying) {
601
- this._sendSyncStep1();
602
- }
603
- }, delay);
661
+ // Request re-sync to recover any lost data - routed through the
662
+ // shared coordinator so this doesn't stack an independent timer on
663
+ // top of any hash-mismatch/gap-confirmed resync already pending.
664
+ this._requestResync();
604
665
  return; // Don't process corrupted message
605
666
  }
606
667
  // Message integrity verified - safe to decode
@@ -612,15 +673,27 @@ export class GenericProvider extends Observable {
612
673
  const encoder = encoding.createEncoder();
613
674
  encoding.writeVarUint(encoder, MESSAGE_SYNC);
614
675
  const syncMessageType = syncProtocol.readSyncMessage(decoder, encoder, this.doc, this);
615
- // If we received SyncStep2, we're synced
616
- if (syncMessageType === syncProtocol.messageYjsSyncStep2 &&
617
- !this._synced) {
618
- this._synced = true;
619
- this.emit('synced', [true]);
676
+ if (syncMessageType === syncProtocol.messageYjsSyncStep2) {
677
+ // If we received SyncStep2, we're synced
678
+ if (!this._synced) {
679
+ this._synced = true;
680
+ this.emit('synced', [true]);
681
+ }
682
+ // Someone else's SyncStep2 reply just arrived - our own pending
683
+ // reply (if any) is now most likely redundant.
684
+ this._cancelPendingSyncReply();
620
685
  }
621
- // Send reply if needed
686
+ // Send reply if needed. Suppression only engages with genuine
687
+ // redundancy (>=2 other known peers via awareness) - below that,
688
+ // there's no "someone else" to rely on, so reply immediately
689
+ // (still rate-limited via _sendSyncReply() as a hard backstop).
622
690
  if (encoding.length(encoder) > 1) {
623
- this._send(encoding.toUint8Array(encoder));
691
+ if (this.awareness.getStates().size >= 3) {
692
+ this._scheduleSyncReply(encoding.toUint8Array(encoder));
693
+ }
694
+ else {
695
+ this._sendSyncReply(encoding.toUint8Array(encoder));
696
+ }
624
697
  }
625
698
  break;
626
699
  }
@@ -671,58 +744,61 @@ export class GenericProvider extends Observable {
671
744
  // Read sequence number and clientID first
672
745
  const seqNum = decoding.readVarUint(decoder);
673
746
  const senderClientID = decoding.readVarUint(decoder);
674
- // Check for duplicate or out-of-order updates
675
- const lastSeq = this._remoteSeqNums.get(senderClientID) ?? -1;
676
- if (seqNum <= lastSeq) {
677
- console.warn(`[GenericProvider] Duplicate or out-of-order update detected from client ${senderClientID}: seqNum ${seqNum} <= lastSeen ${lastSeq}`);
678
- // Skip this update - it's a duplicate or we already have newer data
679
- break;
680
- }
681
- // Check for sequence gap (potential packet loss)
682
- if (lastSeq >= 0 && seqNum > lastSeq + 1) {
683
- const gapSize = seqNum - lastSeq - 1;
684
- console.warn(`[GenericProvider] Sequence gap detected from client ${senderClientID}: expected ${lastSeq + 1}, got ${seqNum} (gap of ${gapSize} messages)`);
685
- // Immediately request sync to recover missing updates
686
- // This is more proactive than waiting for periodic sync or hash mismatch
687
- this._sendSyncStep1();
688
- }
689
- // Update sequence tracker
690
- this._remoteSeqNums.set(senderClientID, seqNum);
691
- // Create encoder for reply with standard MESSAGE_SYNC header
692
- // (replies don't need verification since they're generated immediately)
747
+ // Track for gap detection only — does NOT gate whether we apply
748
+ // the update below (see _trackRemoteSeq() for why).
749
+ this._trackRemoteSeq(senderClientID, seqNum);
750
+ // Always apply the update. Yjs updates are idempotent/commutative,
751
+ // so re-applying an already-seen update is a harmless no-op.
752
+ // Under reordering, a merely-late (not actually duplicate) update
753
+ // must still be applied here — the old "skip if seqNum <= last
754
+ // seen" logic silently dropped such updates forever whenever a
755
+ // later-numbered message happened to arrive first.
693
756
  const encoder = encoding.createEncoder();
694
757
  encoding.writeVarUint(encoder, MESSAGE_SYNC);
695
758
  const syncMessageType = syncProtocol.readSyncMessage(decoder, encoder, this.doc, this);
759
+ // Someone else's SyncStep2 reply just arrived - our own pending
760
+ // reply (if any) is now most likely redundant. Mirrors the
761
+ // MESSAGE_SYNC case: the reply encoded above is always a plain
762
+ // MESSAGE_SYNC-typed message regardless of which message type
763
+ // triggered it, so the same suppression scheme applies here too.
764
+ if (syncMessageType === syncProtocol.messageYjsSyncStep2) {
765
+ this._cancelPendingSyncReply();
766
+ }
696
767
  // Read the expected hash from sender (signed integer)
697
768
  const expectedHash = decoding.readVarInt(decoder);
698
769
  // Compute our local hash after applying the update
699
770
  const localHash = computeDocHash(this.doc);
700
771
  // Verify hash match
701
772
  if (localHash !== expectedHash) {
702
- this._hashMismatchCount++;
703
- const now = Date.now();
704
- // Reset counter if it's been stable for 10 seconds
705
- if (now - this._lastHashMismatchTime > 10000) {
706
- this._hashMismatchCount = 1;
773
+ // If we already know this sender has a suspected reordering gap
774
+ // (see _trackRemoteSeq()/_scheduleGapCheck()), a hash mismatch
775
+ // right now is the *expected* transient state we're missing a
776
+ // piece that's very likely still in flight, not actually
777
+ // diverged. Let the pending gap-check grace period resolve it
778
+ // instead of also escalating the hash-mismatch backoff: under
779
+ // heavy reordering this previously caused a burst of mismatches
780
+ // to rack up the exponential backoff to its 10s cap within a
781
+ // single edit burst, purely from timing, not real divergence.
782
+ // A hash mismatch with NO pending gap (in-order, but still
783
+ // wrong) is not explained by reordering and still escalates
784
+ // normally below.
785
+ const reorderingSuspected = this._gapCheckTimers.has(senderClientID);
786
+ if (!reorderingSuspected) {
787
+ // Push our full state AND request theirs (syncNow() does
788
+ // both). A hash mismatch means the two peers have diverged -
789
+ // one side may have edits the other lacks. Routed through the
790
+ // shared coordinator so this doesn't stack an independent
791
+ // timer on top of any corrupted-message/gap-confirmed resync
792
+ // already pending.
793
+ this._requestResync();
794
+ // Logged with the shared attempt counter (kept as "#N" for
795
+ // compatibility with existing tooling/benchmarks that grep
796
+ // for this exact "Hash mismatch #" pattern) - it now reflects
797
+ // the unified resync-attempt count rather than a
798
+ // hash-mismatch-specific one, since the two escalation
799
+ // counters were merged.
800
+ console.warn(`[GenericProvider] Hash mismatch #${this._resyncAttemptCount} detected! Local: ${localHash}, Expected: ${expectedHash}`);
707
801
  }
708
- this._lastHashMismatchTime = now;
709
- // Exponential backoff: 10ms, 50ms, 250ms, 1.25s, 6.25s, then cap at 10s
710
- const delay = Math.min(10000, 10 * Math.pow(5, this._hashMismatchCount - 1));
711
- console.warn(`[GenericProvider] Hash mismatch #${this._hashMismatchCount} detected! Local: ${localHash}, Expected: ${expectedHash}`);
712
- console.warn(`[GenericProvider] Re-sync scheduled in ${delay}ms...`);
713
- // Push our full state AND request theirs.
714
- // A hash mismatch means the two peers have diverged — one side may
715
- // have edits the other lacks. Calling only _sendSyncStep1() (pull)
716
- // never delivers our own surplus edits to the other side.
717
- setTimeout(() => {
718
- if (this.transport.isConnected && !this._destroying) {
719
- this.syncNow();
720
- }
721
- }, delay);
722
- }
723
- else {
724
- // Hash matched - reset failure counter
725
- this._hashMismatchCount = 0;
726
802
  }
727
803
  // If we received SyncStep2, we're synced (unless hash mismatched)
728
804
  if (syncMessageType === syncProtocol.messageYjsSyncStep2 &&
@@ -731,9 +807,21 @@ export class GenericProvider extends Observable {
731
807
  this._synced = true;
732
808
  this.emit('synced', [true]);
733
809
  }
734
- // Send reply if needed (as standard MESSAGE_SYNC)
810
+ // Send reply if needed (as standard MESSAGE_SYNC). Suppression
811
+ // only engages with genuine redundancy (>=2 other known peers via
812
+ // awareness) - below that, reply immediately (still rate-limited
813
+ // via _sendSyncReply() as a hard backstop). Matches the
814
+ // MESSAGE_SYNC case's gate exactly; without this, a hash-mismatch
815
+ // resync burst under packet loss bypassed suppression entirely,
816
+ // since every peer answering a post-mismatch SyncStep1 replied
817
+ // immediately via this path.
735
818
  if (encoding.length(encoder) > 1) {
736
- this._send(encoding.toUint8Array(encoder));
819
+ if (this.awareness.getStates().size >= 3) {
820
+ this._scheduleSyncReply(encoding.toUint8Array(encoder));
821
+ }
822
+ else {
823
+ this._sendSyncReply(encoding.toUint8Array(encoder));
824
+ }
737
825
  }
738
826
  break;
739
827
  }
@@ -748,22 +836,210 @@ export class GenericProvider extends Observable {
748
836
  }
749
837
  }
750
838
  /**
751
- * Send SyncStep1 message to request missing updates.
752
- * This is sent when first connecting to sync with remote peers.
753
- * Note: SyncStep1 is just a request and doesn't include hash verification.
754
- * Rate limited to prevent spam.
839
+ * Schedule a SyncStep2 reply after a short random delay instead of
840
+ * sending immediately. If another peer's reply is overheard in the
841
+ * meantime (`_cancelPendingSyncReply`), this reply is dropped as
842
+ * redundant - the requester likely already got what it needed.
843
+ *
844
+ * A reply that is already pending when this is called answers a
845
+ * *different* SyncStep1 request (e.g. peer A's request, followed 5ms
846
+ * later by peer B's) - it must not be silently overwritten by the new
847
+ * one. Flush it immediately, then schedule the new reply fresh. The only
848
+ * sanctioned way a reply gets dropped is `_cancelPendingSyncReply()`,
849
+ * because we overheard someone else's SyncStep2 for the SAME request.
755
850
  */
756
- _sendSyncStep1() {
851
+ _scheduleSyncReply(reply) {
852
+ if (this._pendingSyncReplyTimeoutId !== undefined) {
853
+ if (this._pendingSyncReply) {
854
+ this._sendSyncReply(this._pendingSyncReply);
855
+ }
856
+ clearTimeout(this._pendingSyncReplyTimeoutId);
857
+ this._pendingSyncReplyTimeoutId = undefined;
858
+ }
859
+ this._pendingSyncReply = reply;
860
+ const delay = Math.random() * this._syncReplySuppressionMs;
861
+ this._pendingSyncReplyTimeoutId = setTimeout(() => {
862
+ this._pendingSyncReplyTimeoutId = undefined;
863
+ if (this._pendingSyncReply) {
864
+ this._sendSyncReply(this._pendingSyncReply);
865
+ this._pendingSyncReply = null;
866
+ }
867
+ }, delay);
868
+ }
869
+ /** Cancel a pending suppressed reply, if any. */
870
+ _cancelPendingSyncReply() {
871
+ if (this._pendingSyncReplyTimeoutId !== undefined) {
872
+ clearTimeout(this._pendingSyncReplyTimeoutId);
873
+ this._pendingSyncReplyTimeoutId = undefined;
874
+ }
875
+ this._pendingSyncReply = null;
876
+ }
877
+ /**
878
+ * Send a SyncStep2 reply, gated by the same shared per-peer budget as
879
+ * SyncStep1 requests/syncNow() pushes (`_tryReserveSyncSlot()`).
880
+ *
881
+ * Previously SyncStep2 replies were completely unrated - the only
882
+ * defense against redundant replies was the best-effort NACK-style
883
+ * suppression in `_scheduleSyncReply()`/`_cancelPendingSyncReply()`,
884
+ * which itself is just an ordinary broadcast message subject to the same
885
+ * wire corruption as everything else. Under sustained corruption, more
886
+ * competing repliers independently miss the "someone already answered"
887
+ * signal as peer count grows, and none of that traffic was bounded.
888
+ * Measured in test/dummy/bench-corruption-storm.ts: SyncStep2/SyncStep1
889
+ * ratio grew from ~1.1-1.3 at N=2 to ~4.5-5.9 at N=10 (should stay near
890
+ * 1 if suppression alone were sufficient). This is a hard backstop on
891
+ * top of that suppression, not a replacement for it - a rate-limited
892
+ * reply is dropped silently (no warn) since under normal, uncorrupted
893
+ * operation this path is rarely exercised and logging every drop here
894
+ * would itself become log spam exactly when things are already noisy.
895
+ */
896
+ _sendSyncReply(reply) {
897
+ if (!this._tryReserveSyncSlot()) {
898
+ return; // Rate limited - drop the reply silently
899
+ }
900
+ this._send(reply);
901
+ }
902
+ /**
903
+ * Track a received sequence number for reordering-tolerant gap detection.
904
+ * Does not gate whether the update gets applied — only decides whether a
905
+ * gap looks suspicious enough to (eventually) request a resync.
906
+ */
907
+ _trackRemoteSeq(senderClientID, seqNum) {
908
+ let info = this._remoteSeqInfo.get(senderClientID);
909
+ if (!info) {
910
+ info = { highest: -1, seen: new Set() };
911
+ this._remoteSeqInfo.set(senderClientID, info);
912
+ }
913
+ if (info.seen.has(seqNum)) {
914
+ return; // genuine duplicate - nothing new to track
915
+ }
916
+ info.seen.add(seqNum);
917
+ if (seqNum > info.highest) {
918
+ if (info.highest >= 0 && seqNum > info.highest + 1) {
919
+ this._scheduleGapCheck(senderClientID, info.highest + 1, seqNum - 1);
920
+ }
921
+ info.highest = seqNum;
922
+ }
923
+ // Bound memory: forget seqNums far behind the current high-water mark.
924
+ const floor = info.highest - this._seqWindowSize;
925
+ if (floor > 0) {
926
+ for (const s of info.seen) {
927
+ if (s < floor)
928
+ info.seen.delete(s);
929
+ }
930
+ }
931
+ }
932
+ /**
933
+ * Re-check a suspected sequence gap after a short grace period instead of
934
+ * requesting a resync immediately. Pure network reordering (a message
935
+ * that's merely late, not lost) typically resolves itself within the
936
+ * grace window, so this avoids the resync storms that immediate gap
937
+ * detection caused under jitter. Real packet loss still gets caught —
938
+ * just `_gapGraceMs` later — and the periodic sync interval / hash
939
+ * verification remain as further safety nets regardless.
940
+ */
941
+ _scheduleGapCheck(clientID, gapStart, gapEnd) {
942
+ // Only one pending check per sender; a newly-opened gap while a check
943
+ // is already scheduled will still be caught by periodic sync / hash
944
+ // verification even if not by this specific check.
945
+ if (this._gapCheckTimers.has(clientID))
946
+ return;
947
+ const timer = setTimeout(() => {
948
+ this._gapCheckTimers.delete(clientID);
949
+ const info = this._remoteSeqInfo.get(clientID);
950
+ if (!info || this._destroying)
951
+ return;
952
+ let stillMissing = 0;
953
+ for (let s = gapStart; s <= gapEnd; s++) {
954
+ if (!info.seen.has(s))
955
+ stillMissing++;
956
+ }
957
+ if (stillMissing > 0 && this.transport.isConnected) {
958
+ console.warn(`[GenericProvider] Sequence gap confirmed from client ${clientID}: ` +
959
+ `${stillMissing} message(s) still missing after ${this._gapGraceMs}ms grace period`);
960
+ // Routed through the shared coordinator (previously called
961
+ // _sendSyncStep1() directly with NO coalescing at all - the one
962
+ // remaining gap that let this trigger steal rate-limit slots
963
+ // independently of the hash-mismatch/corrupted-message triggers).
964
+ this._requestResync();
965
+ }
966
+ }, this._gapGraceMs);
967
+ this._gapCheckTimers.set(clientID, timer);
968
+ }
969
+ /**
970
+ * Unified entry point for ALL resync triggers (hash mismatch, corrupted
971
+ * message, confirmed sequence gap). Coalesces them behind a single
972
+ * pending timer and a single shared escalation counter, so a burst of
973
+ * triggers from different causes in a short window schedules exactly one
974
+ * resync instead of three independent ones each able to draw on the
975
+ * shared `_tryReserveSyncSlot()` budget on their own.
976
+ *
977
+ * Always resolves to `syncNow()` (push + pull) rather than distinguishing
978
+ * a push-only/pull-only variant per trigger. `syncNow()`'s push half is
979
+ * already a no-op when there's nothing to send (it only calls
980
+ * `_sendUpdate()` when `update.length > 0`), so unifying on push+pull is
981
+ * strictly simpler than threading a `push` flag through a *shared*
982
+ * coordinator (where the "right" answer for an absorbed trigger is
983
+ * ambiguous anyway - was it push-worthy or not?). It also closes a latent
984
+ * gap where the corrupted-message and gap-confirmed triggers previously
985
+ * called pull-only `_sendSyncStep1()` and could never deliver this peer's
986
+ * own surplus edits made during a divergence window.
987
+ */
988
+ _requestResync() {
989
+ // Coalesced: if a resync is already pending (regardless of which
990
+ // trigger scheduled it), this trigger is absorbed into it instead of
991
+ // stacking another independent timer/broadcast. Escalation only
992
+ // advances when we actually schedule a NEW timer below - incrementing
993
+ // unconditionally here (once per absorbed trigger too) would let a
994
+ // burst of many corrupted/mismatched messages while one resync is
995
+ // already pending ratchet the counter straight to its cap, so the
996
+ // *next* resync (after this one fires) always schedules at the max
997
+ // backoff instead of escalating gradually.
998
+ if (this._pendingResyncTimeoutId !== undefined) {
999
+ return;
1000
+ }
1001
+ this._resyncAttemptCount++;
1002
+ const now = Date.now();
1003
+ // Reset the escalation counter if it's been stable for 10 seconds -
1004
+ // same quiet-period reset the old per-trigger counters used.
1005
+ if (now - this._lastResyncAttemptTime > 10000) {
1006
+ this._resyncAttemptCount = 1;
1007
+ }
1008
+ this._lastResyncAttemptTime = now;
1009
+ // Exponential backoff: 100ms, 500ms, 2.5s, then cap at 5s.
1010
+ const delay = Math.min(5000, 100 * Math.pow(5, Math.min(this._resyncAttemptCount - 1, 3)));
1011
+ console.warn(`[GenericProvider] Resync scheduled in ${delay}ms (attempt #${this._resyncAttemptCount})...`);
1012
+ this._pendingResyncTimeoutId = setTimeout(() => {
1013
+ this._pendingResyncTimeoutId = undefined;
1014
+ if (this.transport.isConnected && !this._destroying) {
1015
+ this.syncNow();
1016
+ }
1017
+ }, delay);
1018
+ }
1019
+ /**
1020
+ * Reserve a slot in the sync rate limiter (max `_maxSyncRequestsPerWindow`
1021
+ * per `_syncRequestWindowMs`), recording the request if there's room.
1022
+ * Shared by `_sendSyncStep1()` and `syncNow()` so a burst of triggers from
1023
+ * different sources (periodic sync, hash-mismatch resyncs, gap-check
1024
+ * confirmations) draws from one combined budget instead of each having
1025
+ * its own uncapped or separately-capped allowance.
1026
+ */
1027
+ _tryReserveSyncSlot() {
757
1028
  const now = Date.now();
758
1029
  // Clean up old entries outside the rate limit window
759
1030
  this._syncRequestTimes = this._syncRequestTimes.filter((t) => now - t < this._syncRequestWindowMs);
760
- // Check rate limit
761
1031
  if (this._syncRequestTimes.length >= this._maxSyncRequestsPerWindow) {
762
- console.warn(`[GenericProvider] Sync rate limit exceeded (${this._maxSyncRequestsPerWindow} requests per ${this._syncRequestWindowMs / 1000}s), throttling...`);
763
- return; // Drop the request
1032
+ return false;
764
1033
  }
765
- // Record this request
766
1034
  this._syncRequestTimes.push(now);
1035
+ return true;
1036
+ }
1037
+ /**
1038
+ * Encode and send a SyncStep1 message requesting missing updates.
1039
+ * Does not check the rate limiter itself - callers must reserve a slot
1040
+ * via `_tryReserveSyncSlot()` first.
1041
+ */
1042
+ _writeSyncStep1() {
767
1043
  const encoder = encoding.createEncoder();
768
1044
  // SyncStep1 is always sent as standard MESSAGE_SYNC (no verification)
769
1045
  // It's just a request, not an assertion of state
@@ -771,6 +1047,19 @@ export class GenericProvider extends Observable {
771
1047
  syncProtocol.writeSyncStep1(encoder, this.doc);
772
1048
  this._send(encoding.toUint8Array(encoder));
773
1049
  }
1050
+ /**
1051
+ * Send SyncStep1 message to request missing updates.
1052
+ * This is sent when first connecting to sync with remote peers.
1053
+ * Note: SyncStep1 is just a request and doesn't include hash verification.
1054
+ * Rate limited to prevent spam.
1055
+ */
1056
+ _sendSyncStep1() {
1057
+ if (!this._tryReserveSyncSlot()) {
1058
+ console.warn(`[GenericProvider] Sync rate limit exceeded (${this._maxSyncRequestsPerWindow} requests per ${this._syncRequestWindowMs / 1000}s), throttling...`);
1059
+ return; // Drop the request
1060
+ }
1061
+ this._writeSyncStep1();
1062
+ }
774
1063
  /**
775
1064
  * Send a document update to the transport.
776
1065
  * If verifyUpdates is enabled, includes sequence number and document hash for ordering and desync detection.