@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.d.ts +140 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +402 -113
- package/dist/index.js.map +1 -1
- package/dist/providers/gun/index.d.ts +18 -0
- package/dist/providers/gun/index.d.ts.map +1 -1
- package/dist/providers/gun/index.js +61 -12
- package/dist/providers/gun/index.js.map +1 -1
- package/dist/providers/matrix/index.d.ts +6 -0
- package/dist/providers/matrix/index.d.ts.map +1 -1
- package/dist/providers/matrix/index.js +6 -0
- package/dist/providers/matrix/index.js.map +1 -1
- package/dist/providers/nostr/index.d.ts +7 -0
- package/dist/providers/nostr/index.d.ts.map +1 -1
- package/dist/providers/nostr/index.js +7 -0
- package/dist/providers/nostr/index.js.map +1 -1
- package/dist/providers/simple-peer/index.d.ts +29 -0
- package/dist/providers/simple-peer/index.d.ts.map +1 -1
- package/dist/providers/simple-peer/index.js +93 -12
- package/dist/providers/simple-peer/index.js.map +1 -1
- package/dist/transport.d.ts +11 -0
- package/dist/transport.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -81,16 +81,24 @@ function unwrapAndVerifyMessage(wrapped) {
|
|
|
81
81
|
return message;
|
|
82
82
|
}
|
|
83
83
|
/**
|
|
84
|
-
* Compute a
|
|
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
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
353
|
-
this.
|
|
354
|
-
this.
|
|
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
|
-
//
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
//
|
|
485
|
-
|
|
486
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
!this._synced) {
|
|
618
|
-
|
|
619
|
-
|
|
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.
|
|
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
|
-
//
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
//
|
|
682
|
-
|
|
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
|
|
703
|
-
|
|
704
|
-
//
|
|
705
|
-
|
|
706
|
-
|
|
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.
|
|
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
|
-
*
|
|
752
|
-
*
|
|
753
|
-
*
|
|
754
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|