@edryslabs/genericprovider 1.0.2 → 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
@@ -11,6 +11,9 @@ const MESSAGE_AWARENESS = 1;
11
11
  const MESSAGE_PUBSUB = 2;
12
12
  const MESSAGE_SYNC_VERIFIED = 3; // Sync message with hash verification
13
13
  const MESSAGE_PUBSUB_TARGETED = 4; // Pub/sub message aimed at a single target
14
+ // Sub-channel inside a MESSAGE_AWARENESS frame: [opcode 1][channel][update].
15
+ const AWARENESS_CHANNEL_MAIN = 0;
16
+ const AWARENESS_CHANNEL_APP = 1;
14
17
  /**
15
18
  * CRC32 lookup table for fast computation.
16
19
  * Generated once and reused for all CRC calculations.
@@ -78,16 +81,24 @@ function unwrapAndVerifyMessage(wrapped) {
78
81
  return message;
79
82
  }
80
83
  /**
81
- * Compute a simple hash of document state for desync detection.
82
- * Uses a fast non-cryptographic hash for performance.
84
+ * Compute a cheap hash of document state for desync detection.
83
85
  *
84
- * Hashes the state VECTOR, not encodeStateAsUpdate: the full update byte stream
85
- * is NOT canonical across CRDT-convergent replicas (client-block and tombstone
86
- * ordering differ per peer), so hashing it flags false divergence and triggers
87
- * an endless re-sync loop. The state vector (clientID -> clock) is serialized in
88
- * sorted clientID order by Yjs, so two convergent docs hash identically, while a
89
- * missed update still shows up as a differing clock which is exactly the
90
- * "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.)
91
102
  */
92
103
  function computeDocHash(doc) {
93
104
  const state = Y.encodeStateVector(doc);
@@ -211,19 +222,41 @@ export class GenericProvider extends Observable {
211
222
  // BroadcastChannel state for cross-tab sync
212
223
  this._bcChannel = '';
213
224
  this._bcConnected = false;
214
- // Hash verification tracking for exponential backoff
215
- this._hashMismatchCount = 0;
216
- 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;
217
240
  // Rate limiting for sync requests
218
241
  this._syncRequestTimes = [];
219
- this._maxSyncRequestsPerWindow = 20; // max requests per 10 seconds
220
- 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;
221
251
  // Sequence numbers for causal ordering
222
252
  this._localSeqNum = 0; // Our sequence number counter
223
- this._remoteSeqNums = new Map(); // clientID -> last seen seqNum
224
- // Message integrity tracking
225
- this._corruptedMessageCount = 0; // Track rejected corrupted messages
226
- 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();
227
260
  // Update batching/debouncing
228
261
  this._batchUpdates = 0; // milliseconds delay (0 = disabled)
229
262
  this._pendingUpdate = null;
@@ -231,6 +264,9 @@ export class GenericProvider extends Observable {
231
264
  this._awarenessInterval = 100; // ms between awareness broadcasts
232
265
  this._pendingAwarenessClients = new Set();
233
266
  this._lastAwarenessTime = 0;
267
+ // Independent throttle state for the app awareness channel.
268
+ this._pendingAppAwarenessClients = new Set();
269
+ this._lastAppAwarenessTime = 0;
234
270
  // Origins whose updates are never sent to the transport (local-only txns).
235
271
  this._excludeOrigins = new Set();
236
272
  // Connect-time sync strategy: 'push-pull' (default) sends full local state
@@ -240,14 +276,22 @@ export class GenericProvider extends Observable {
240
276
  this.transport = transport;
241
277
  this.pubsub = new PubSubChannel(this);
242
278
  this.awareness = options.awareness || new awarenessProtocol.Awareness(doc);
279
+ this.appAwareness =
280
+ options.appAwareness || new awarenessProtocol.Awareness(doc);
243
281
  this._syncInterval = options.syncInterval ?? 5000;
244
282
  this._verifyUpdates = options.verifyUpdates ?? true;
245
- this._batchUpdates = options.batchUpdates ?? 0;
283
+ this._batchUpdates =
284
+ options.batchUpdates ?? transport.preferredBatchMs ?? 0;
246
285
  this._disableBc = options.disableBc ?? false;
247
286
  this._awarenessInterval = options.awarenessInterval ?? 100;
248
287
  this._excludeOrigins = new Set(options.excludeOrigins ?? []);
249
288
  this._localId = options.localId;
250
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;
251
295
  this._setupDocumentSync();
252
296
  this._setupAwarenessSync();
253
297
  }
@@ -306,18 +350,15 @@ export class GenericProvider extends Observable {
306
350
  }
307
351
  // Broadcast local awareness state
308
352
  this._broadcastAwareness([this.doc.clientID]);
353
+ this._broadcastAwareness([this.doc.clientID], AWARENESS_CHANNEL_APP);
309
354
  // Start periodic sync to handle packet loss
310
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.
311
358
  if (this._syncInterval > 0) {
312
359
  this._syncIntervalId = setInterval(() => {
313
360
  if (this.transport.isConnected && !this._destroying) {
314
- // Check rate limit before syncing
315
- const now = Date.now();
316
- this._syncRequestTimes = this._syncRequestTimes.filter((t) => now - t < this._syncRequestWindowMs);
317
- if (this._syncRequestTimes.length < this._maxSyncRequestsPerWindow) {
318
- this._sendSyncStep1();
319
- }
320
- // If rate limited, skip this periodic sync - will try again next interval
361
+ this._sendSyncStep1();
321
362
  }
322
363
  }, this._syncInterval);
323
364
  }
@@ -340,9 +381,26 @@ export class GenericProvider extends Observable {
340
381
  clearInterval(this._syncIntervalId);
341
382
  this._syncIntervalId = undefined;
342
383
  }
343
- // Reset corruption tracking
344
- this._corruptedMessageCount = 0;
345
- 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();
346
404
  // Flush any pending batched updates before disconnecting
347
405
  if (this._batchTimeoutId !== undefined) {
348
406
  clearTimeout(this._batchTimeoutId);
@@ -365,6 +423,16 @@ export class GenericProvider extends Observable {
365
423
  }
366
424
  }
367
425
  this._pendingAwarenessClients.clear();
426
+ // Flush pending app awareness updates before disconnecting
427
+ if (this._appAwarenessTimeoutId !== undefined) {
428
+ clearTimeout(this._appAwarenessTimeoutId);
429
+ this._appAwarenessTimeoutId = undefined;
430
+ if (this._pendingAppAwarenessClients.size > 0 &&
431
+ this.transport.isConnected) {
432
+ this._sendAwarenessNow(Array.from(this._pendingAppAwarenessClients), AWARENESS_CHANNEL_APP);
433
+ }
434
+ }
435
+ this._pendingAppAwarenessClients.clear();
368
436
  // Disconnect BroadcastChannel
369
437
  this._disconnectBroadcastChannel();
370
438
  if (this._unsubscribeTransport) {
@@ -373,6 +441,7 @@ export class GenericProvider extends Observable {
373
441
  }
374
442
  // Mark local client as offline in awareness
375
443
  awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], 'disconnect');
444
+ awarenessProtocol.removeAwarenessStates(this.appAwareness, [this.doc.clientID], 'disconnect');
376
445
  this.transport.disconnect();
377
446
  this._synced = false;
378
447
  this._setStatus({ state: 'disconnected' });
@@ -388,6 +457,14 @@ export class GenericProvider extends Observable {
388
457
  clearInterval(this._syncIntervalId);
389
458
  this._syncIntervalId = undefined;
390
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();
391
468
  // Flush any pending batched updates before destroying
392
469
  if (this._batchTimeoutId !== undefined) {
393
470
  clearTimeout(this._batchTimeoutId);
@@ -404,17 +481,22 @@ export class GenericProvider extends Observable {
404
481
  this.doc.off('update', this._updateHandler);
405
482
  this._updateHandler = undefined;
406
483
  }
407
- // Remove awareness update listener
484
+ // Remove awareness update listeners
408
485
  if (this._awarenessUpdateHandler) {
409
486
  this.awareness.off('update', this._awarenessUpdateHandler);
410
487
  this._awarenessUpdateHandler = undefined;
411
488
  }
489
+ if (this._appAwarenessUpdateHandler) {
490
+ this.appAwareness.off('update', this._appAwarenessUpdateHandler);
491
+ this._appAwarenessUpdateHandler = undefined;
492
+ }
412
493
  // Remove beforeunload handler
413
494
  if (this._beforeUnloadHandler && typeof window !== 'undefined') {
414
495
  window.removeEventListener('beforeunload', this._beforeUnloadHandler);
415
496
  this._beforeUnloadHandler = undefined;
416
497
  }
417
498
  this.awareness.destroy();
499
+ this.appAwareness.destroy();
418
500
  super.destroy();
419
501
  }
420
502
  /**
@@ -450,16 +532,32 @@ export class GenericProvider extends Observable {
450
532
  console.warn('Cannot sync: transport not connected');
451
533
  return;
452
534
  }
453
- // Send our current document state to all peers
454
- // This ensures any changes made while offline are transmitted
455
- const update = Y.encodeStateAsUpdate(this.doc);
456
- if (update.length > 0) {
457
- 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();
458
555
  }
459
- // Send sync request to get updates from others
460
- this._sendSyncStep1();
461
- // 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.
462
559
  this._broadcastAwareness([this.doc.clientID]);
560
+ this._broadcastAwareness([this.doc.clientID], AWARENESS_CHANNEL_APP);
463
561
  }
464
562
  /**
465
563
  * Setup automatic document synchronization.
@@ -532,10 +630,16 @@ export class GenericProvider extends Observable {
532
630
  this._broadcastAwareness(changedClients);
533
631
  };
534
632
  this.awareness.on('update', this._awarenessUpdateHandler);
633
+ this._appAwarenessUpdateHandler = ({ added, updated, removed, }, _origin) => {
634
+ const changedClients = added.concat(updated).concat(removed);
635
+ this._broadcastAwareness(changedClients, AWARENESS_CHANNEL_APP);
636
+ };
637
+ this.appAwareness.on('update', this._appAwarenessUpdateHandler);
535
638
  // Cleanup: mark as offline and disconnect BC when page unloads
536
639
  if (typeof window !== 'undefined') {
537
640
  this._beforeUnloadHandler = () => {
538
641
  awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], 'window unload');
642
+ awarenessProtocol.removeAwarenessStates(this.appAwareness, [this.doc.clientID], 'window unload');
539
643
  // Disconnect BroadcastChannel to notify other tabs
540
644
  this._disconnectBroadcastChannel();
541
645
  };
@@ -552,23 +656,12 @@ export class GenericProvider extends Observable {
552
656
  const message = unwrapAndVerifyMessage(data);
553
657
  if (message === null) {
554
658
  // Message is corrupted - reject it immediately
555
- this._corruptedMessageCount++;
556
- const now = Date.now();
557
- // Reset counter if it's been stable for 10 seconds
558
- if (now - this._lastCorruptedMessageTime > 10000) {
559
- this._corruptedMessageCount = 1;
560
- }
561
- this._lastCorruptedMessageTime = now;
562
- console.warn(`[GenericProvider] 💥 Corrupted message rejected (#${this._corruptedMessageCount}): CRC32 checksum mismatch. ` +
659
+ console.warn(`[GenericProvider] 💥 Corrupted message rejected: CRC32 checksum mismatch. ` +
563
660
  `This is expected if data corruption simulation is enabled.`);
564
- // Request re-sync to recover any lost data
565
- // Use exponential backoff: 100ms, 500ms, 2.5s, then cap at 5s
566
- const delay = Math.min(5000, 100 * Math.pow(5, Math.min(this._corruptedMessageCount - 1, 3)));
567
- setTimeout(() => {
568
- if (this.transport.isConnected && !this._destroying) {
569
- this._sendSyncStep1();
570
- }
571
- }, 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();
572
665
  return; // Don't process corrupted message
573
666
  }
574
667
  // Message integrity verified - safe to decode
@@ -580,20 +673,35 @@ export class GenericProvider extends Observable {
580
673
  const encoder = encoding.createEncoder();
581
674
  encoding.writeVarUint(encoder, MESSAGE_SYNC);
582
675
  const syncMessageType = syncProtocol.readSyncMessage(decoder, encoder, this.doc, this);
583
- // If we received SyncStep2, we're synced
584
- if (syncMessageType === syncProtocol.messageYjsSyncStep2 &&
585
- !this._synced) {
586
- this._synced = true;
587
- 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();
588
685
  }
589
- // 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).
590
690
  if (encoding.length(encoder) > 1) {
591
- 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
+ }
592
697
  }
593
698
  break;
594
699
  }
595
700
  case MESSAGE_AWARENESS: {
596
- awarenessProtocol.applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(decoder), this);
701
+ const channel = decoding.readVarUint(decoder);
702
+ awarenessProtocol.applyAwarenessUpdate(channel === AWARENESS_CHANNEL_APP
703
+ ? this.appAwareness
704
+ : this.awareness, decoding.readVarUint8Array(decoder), this);
597
705
  break;
598
706
  }
599
707
  case MESSAGE_PUBSUB: {
@@ -636,58 +744,61 @@ export class GenericProvider extends Observable {
636
744
  // Read sequence number and clientID first
637
745
  const seqNum = decoding.readVarUint(decoder);
638
746
  const senderClientID = decoding.readVarUint(decoder);
639
- // Check for duplicate or out-of-order updates
640
- const lastSeq = this._remoteSeqNums.get(senderClientID) ?? -1;
641
- if (seqNum <= lastSeq) {
642
- console.warn(`[GenericProvider] Duplicate or out-of-order update detected from client ${senderClientID}: seqNum ${seqNum} <= lastSeen ${lastSeq}`);
643
- // Skip this update - it's a duplicate or we already have newer data
644
- break;
645
- }
646
- // Check for sequence gap (potential packet loss)
647
- if (lastSeq >= 0 && seqNum > lastSeq + 1) {
648
- const gapSize = seqNum - lastSeq - 1;
649
- console.warn(`[GenericProvider] Sequence gap detected from client ${senderClientID}: expected ${lastSeq + 1}, got ${seqNum} (gap of ${gapSize} messages)`);
650
- // Immediately request sync to recover missing updates
651
- // This is more proactive than waiting for periodic sync or hash mismatch
652
- this._sendSyncStep1();
653
- }
654
- // Update sequence tracker
655
- this._remoteSeqNums.set(senderClientID, seqNum);
656
- // Create encoder for reply with standard MESSAGE_SYNC header
657
- // (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.
658
756
  const encoder = encoding.createEncoder();
659
757
  encoding.writeVarUint(encoder, MESSAGE_SYNC);
660
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
+ }
661
767
  // Read the expected hash from sender (signed integer)
662
768
  const expectedHash = decoding.readVarInt(decoder);
663
769
  // Compute our local hash after applying the update
664
770
  const localHash = computeDocHash(this.doc);
665
771
  // Verify hash match
666
772
  if (localHash !== expectedHash) {
667
- this._hashMismatchCount++;
668
- const now = Date.now();
669
- // Reset counter if it's been stable for 10 seconds
670
- if (now - this._lastHashMismatchTime > 10000) {
671
- 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}`);
672
801
  }
673
- this._lastHashMismatchTime = now;
674
- // Exponential backoff: 10ms, 50ms, 250ms, 1.25s, 6.25s, then cap at 10s
675
- const delay = Math.min(10000, 10 * Math.pow(5, this._hashMismatchCount - 1));
676
- console.warn(`[GenericProvider] Hash mismatch #${this._hashMismatchCount} detected! Local: ${localHash}, Expected: ${expectedHash}`);
677
- console.warn(`[GenericProvider] Re-sync scheduled in ${delay}ms...`);
678
- // Push our full state AND request theirs.
679
- // A hash mismatch means the two peers have diverged — one side may
680
- // have edits the other lacks. Calling only _sendSyncStep1() (pull)
681
- // never delivers our own surplus edits to the other side.
682
- setTimeout(() => {
683
- if (this.transport.isConnected && !this._destroying) {
684
- this.syncNow();
685
- }
686
- }, delay);
687
- }
688
- else {
689
- // Hash matched - reset failure counter
690
- this._hashMismatchCount = 0;
691
802
  }
692
803
  // If we received SyncStep2, we're synced (unless hash mismatched)
693
804
  if (syncMessageType === syncProtocol.messageYjsSyncStep2 &&
@@ -696,9 +807,21 @@ export class GenericProvider extends Observable {
696
807
  this._synced = true;
697
808
  this.emit('synced', [true]);
698
809
  }
699
- // 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.
700
818
  if (encoding.length(encoder) > 1) {
701
- 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
+ }
702
825
  }
703
826
  break;
704
827
  }
@@ -713,22 +836,210 @@ export class GenericProvider extends Observable {
713
836
  }
714
837
  }
715
838
  /**
716
- * Send SyncStep1 message to request missing updates.
717
- * This is sent when first connecting to sync with remote peers.
718
- * Note: SyncStep1 is just a request and doesn't include hash verification.
719
- * 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.
720
850
  */
721
- _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() {
722
1028
  const now = Date.now();
723
1029
  // Clean up old entries outside the rate limit window
724
1030
  this._syncRequestTimes = this._syncRequestTimes.filter((t) => now - t < this._syncRequestWindowMs);
725
- // Check rate limit
726
1031
  if (this._syncRequestTimes.length >= this._maxSyncRequestsPerWindow) {
727
- console.warn(`[GenericProvider] Sync rate limit exceeded (${this._maxSyncRequestsPerWindow} requests per ${this._syncRequestWindowMs / 1000}s), throttling...`);
728
- return; // Drop the request
1032
+ return false;
729
1033
  }
730
- // Record this request
731
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() {
732
1043
  const encoder = encoding.createEncoder();
733
1044
  // SyncStep1 is always sent as standard MESSAGE_SYNC (no verification)
734
1045
  // It's just a request, not an assertion of state
@@ -736,6 +1047,19 @@ export class GenericProvider extends Observable {
736
1047
  syncProtocol.writeSyncStep1(encoder, this.doc);
737
1048
  this._send(encoding.toUint8Array(encoder));
738
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
+ }
739
1063
  /**
740
1064
  * Send a document update to the transport.
741
1065
  * If verifyUpdates is enabled, includes sequence number and document hash for ordering and desync detection.
@@ -760,15 +1084,6 @@ export class GenericProvider extends Observable {
760
1084
  }
761
1085
  this._send(encoding.toUint8Array(encoder));
762
1086
  }
763
- /**
764
- * Send awareness update to the transport.
765
- */
766
- _sendAwarenessUpdate(changedClients) {
767
- const encoder = encoding.createEncoder();
768
- encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
769
- encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients));
770
- this._send(encoding.toUint8Array(encoder));
771
- }
772
1087
  /**
773
1088
  * Send a pub/sub message.
774
1089
  * Internal method called by PubSubChannel.
@@ -839,45 +1154,62 @@ export class GenericProvider extends Observable {
839
1154
  * Throttled to prevent awareness updates from flooding document sync.
840
1155
  * Multiple rapid updates are batched together.
841
1156
  */
842
- _broadcastAwareness(clients) {
1157
+ _broadcastAwareness(clients, channel = AWARENESS_CHANNEL_MAIN) {
843
1158
  if (clients.length === 0)
844
1159
  return;
1160
+ const isApp = channel === AWARENESS_CHANNEL_APP;
1161
+ const pending = isApp
1162
+ ? this._pendingAppAwarenessClients
1163
+ : this._pendingAwarenessClients;
845
1164
  // If throttling is disabled, send immediately
846
1165
  if (this._awarenessInterval <= 0) {
847
- this._sendAwarenessNow(clients);
1166
+ this._sendAwarenessNow(clients, channel);
848
1167
  return;
849
1168
  }
850
1169
  // Add clients to pending set
851
1170
  for (const client of clients) {
852
- this._pendingAwarenessClients.add(client);
1171
+ pending.add(client);
853
1172
  }
854
1173
  // If we already have a scheduled broadcast, let it handle the batched clients
855
- if (this._awarenessTimeoutId !== undefined) {
1174
+ if ((isApp ? this._appAwarenessTimeoutId : this._awarenessTimeoutId) !==
1175
+ undefined) {
856
1176
  return;
857
1177
  }
858
1178
  // Calculate delay - respect minimum interval since last broadcast
859
1179
  const now = Date.now();
860
- const timeSinceLastBroadcast = now - this._lastAwarenessTime;
861
- const delay = Math.max(0, this._awarenessInterval - timeSinceLastBroadcast);
1180
+ const lastTime = isApp ? this._lastAppAwarenessTime : this._lastAwarenessTime;
1181
+ const delay = Math.max(0, this._awarenessInterval - (now - lastTime));
862
1182
  // Schedule the batched broadcast
863
- this._awarenessTimeoutId = setTimeout(() => {
864
- this._awarenessTimeoutId = undefined;
865
- this._lastAwarenessTime = Date.now();
1183
+ const timeoutId = setTimeout(() => {
1184
+ if (isApp) {
1185
+ this._appAwarenessTimeoutId = undefined;
1186
+ this._lastAppAwarenessTime = Date.now();
1187
+ }
1188
+ else {
1189
+ this._awarenessTimeoutId = undefined;
1190
+ this._lastAwarenessTime = Date.now();
1191
+ }
866
1192
  // Send all pending clients in one message
867
- const clientsToSend = Array.from(this._pendingAwarenessClients);
868
- this._pendingAwarenessClients.clear();
1193
+ const clientsToSend = Array.from(pending);
1194
+ pending.clear();
869
1195
  if (clientsToSend.length > 0) {
870
- this._sendAwarenessNow(clientsToSend);
1196
+ this._sendAwarenessNow(clientsToSend, channel);
871
1197
  }
872
1198
  }, delay);
1199
+ if (isApp)
1200
+ this._appAwarenessTimeoutId = timeoutId;
1201
+ else
1202
+ this._awarenessTimeoutId = timeoutId;
873
1203
  }
874
1204
  /**
875
1205
  * Send awareness update immediately without throttling.
876
1206
  */
877
- _sendAwarenessNow(clients) {
1207
+ _sendAwarenessNow(clients, channel = AWARENESS_CHANNEL_MAIN) {
1208
+ const aw = channel === AWARENESS_CHANNEL_APP ? this.appAwareness : this.awareness;
878
1209
  const encoder = encoding.createEncoder();
879
1210
  encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
880
- encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, clients));
1211
+ encoding.writeVarUint(encoder, channel);
1212
+ encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(aw, clients));
881
1213
  this._send(encoding.toUint8Array(encoder));
882
1214
  }
883
1215
  /**
@@ -920,13 +1252,21 @@ export class GenericProvider extends Observable {
920
1252
  bc.publish(this._bcChannel, wrapMessageWithChecksum(encoding.toUint8Array(encoderState)), this);
921
1253
  // Broadcast local awareness state via BroadcastChannel (wrapped with CRC32)
922
1254
  if (this.awareness.getLocalState() !== null) {
923
- const encoderAwareness = encoding.createEncoder();
924
- encoding.writeVarUint(encoderAwareness, MESSAGE_AWARENESS);
925
- encoding.writeVarUint8Array(encoderAwareness, awarenessProtocol.encodeAwarenessUpdate(this.awareness, [
926
- this.doc.clientID,
927
- ]));
928
- bc.publish(this._bcChannel, wrapMessageWithChecksum(encoding.toUint8Array(encoderAwareness)), this);
1255
+ this._publishAwarenessToBroadcastChannel(this.awareness, AWARENESS_CHANNEL_MAIN);
929
1256
  }
1257
+ if (this.appAwareness.getLocalState() !== null) {
1258
+ this._publishAwarenessToBroadcastChannel(this.appAwareness, AWARENESS_CHANNEL_APP);
1259
+ }
1260
+ }
1261
+ /**
1262
+ * Encode and publish an awareness update for the local client to the BroadcastChannel.
1263
+ */
1264
+ _publishAwarenessToBroadcastChannel(awareness, channel) {
1265
+ const encoder = encoding.createEncoder();
1266
+ encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
1267
+ encoding.writeVarUint(encoder, channel);
1268
+ encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(awareness, [this.doc.clientID]));
1269
+ bc.publish(this._bcChannel, wrapMessageWithChecksum(encoding.toUint8Array(encoder)), this);
930
1270
  }
931
1271
  /**
932
1272
  * Disconnect from BroadcastChannel and mark local client as offline.
@@ -936,10 +1276,16 @@ export class GenericProvider extends Observable {
936
1276
  return;
937
1277
  }
938
1278
  // Broadcast awareness state with null (indicating disconnect) - wrapped with CRC32
939
- const encoder = encoding.createEncoder();
940
- encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
941
- encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, [this.doc.clientID], new Map()));
942
- bc.publish(this._bcChannel, wrapMessageWithChecksum(encoding.toUint8Array(encoder)), this);
1279
+ for (const [awareness, channel] of [
1280
+ [this.awareness, AWARENESS_CHANNEL_MAIN],
1281
+ [this.appAwareness, AWARENESS_CHANNEL_APP],
1282
+ ]) {
1283
+ const encoder = encoding.createEncoder();
1284
+ encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
1285
+ encoding.writeVarUint(encoder, channel);
1286
+ encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(awareness, [this.doc.clientID], new Map()));
1287
+ bc.publish(this._bcChannel, wrapMessageWithChecksum(encoding.toUint8Array(encoder)), this);
1288
+ }
943
1289
  // Unsubscribe from channel
944
1290
  bc.unsubscribe(this._bcChannel, this._bcSubscriber);
945
1291
  this._bcConnected = false;