yrby 0.2.3 → 0.3.1

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.
data/ext/yrby/src/lib.rs CHANGED
@@ -8,7 +8,10 @@ use yrs::{Doc, GetString, ReadTxn, Transact};
8
8
 
9
9
  mod protocol;
10
10
  mod read;
11
- use protocol::{classify_message, merged_doc_update, update_advances_doc, update_is_ready};
11
+ use protocol::{
12
+ classify_message, has_pending, integrated_update, merged_doc_update, update_advances_doc,
13
+ update_is_ready,
14
+ };
12
15
 
13
16
  /// Wrapper around yrs Doc.
14
17
  ///
@@ -157,9 +160,11 @@ impl RbDoc {
157
160
  fn read_text(&self, name: String) -> Option<String> {
158
161
  let doc = &self.0;
159
162
  nogvl(move || {
160
- doc.transact()
161
- .get_text(name.as_str())
162
- .map(|t| t.get_string(&doc.transact()))
163
+ // Exactly ONE transaction per call. Opening a second while the
164
+ // first is still held deadlocks against a waiting writer — and
165
+ // inside nogvl that hang can't be interrupted.
166
+ let txn = doc.transact();
167
+ txn.get_text(name.as_str()).map(|t| t.get_string(&txn))
163
168
  })
164
169
  }
165
170
 
@@ -188,6 +193,28 @@ impl RbDoc {
188
193
  })
189
194
  }
190
195
 
196
+ /// True if the doc holds un-integrable pending structs or a pending delete
197
+ /// set — content that couldn't integrate because a causally-prior update is
198
+ /// missing. Such content is a recovery buffer, not document state; it heals if
199
+ /// the missing dependency later arrives. A pure read.
200
+ fn pending(&self) -> bool {
201
+ let doc = &self.0;
202
+ nogvl(move || has_pending(doc))
203
+ }
204
+
205
+ /// Like `encode_state_as_update` (full state), but **gap-free**: it excludes
206
+ /// any pending (un-integrable) structs and pending delete set. Use this when
207
+ /// persisting or serving state that other peers will apply — serving pending
208
+ /// content poisons their sync. Non-destructive: this doc keeps its pending, so
209
+ /// a genuine gap still heals if its dependency arrives. (`encode_state_as_update`
210
+ /// stays lossless for raw-update recovery.)
211
+ fn compacted_state_update(&self) -> Result<RString, Error> {
212
+ let doc = &self.0;
213
+ let update = nogvl(move || integrated_update(doc, &yrs::StateVector::default()))
214
+ .map_err(yrb_error)?;
215
+ Ok(binary_string(&update))
216
+ }
217
+
191
218
  /// Encode state as update (optionally diffed against a state vector)
192
219
  fn encode_state_as_update(&self, args: &[Value]) -> Result<RString, Error> {
193
220
  let sv_bytes: Option<Vec<u8>> = if args.is_empty() {
@@ -263,9 +290,13 @@ impl RbDoc {
263
290
  match msg {
264
291
  Message::Sync(sync_msg) => match sync_msg {
265
292
  SyncMessage::SyncStep1(sv) => {
266
- // Respond with SyncStep2
267
- let txn = doc.transact();
268
- let update = txn.encode_state_as_update_v1(&sv);
293
+ // Respond with SyncStep2 carrying only *integrated*
294
+ // state. Never hand a peer un-integrable pending
295
+ // structs: the peer would park the same pending
296
+ // forever and the state-vector/content mismatch drives
297
+ // endless resync traffic. (integrated_update is a no-op
298
+ // fast path when nothing is pending.)
299
+ let update = integrated_update(doc, &sv)?;
269
300
  let response = Message::Sync(SyncMessage::SyncStep2(update));
270
301
  Ok((0, 0, response.encode_v1()))
271
302
  }
@@ -366,6 +397,11 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
366
397
  doc_class.define_method("read_text", method!(RbDoc::read_text, 1))?;
367
398
  doc_class.define_method("read_xml", method!(RbDoc::read_xml, 1))?;
368
399
  doc_class.define_method("read_map", method!(RbDoc::read_map, 1))?;
400
+ doc_class.define_method("pending?", method!(RbDoc::pending, 0))?;
401
+ doc_class.define_method(
402
+ "compacted_state_update",
403
+ method!(RbDoc::compacted_state_update, 0),
404
+ )?;
369
405
  doc_class.define_method("update_ready?", method!(RbDoc::update_ready, 1))?;
370
406
  doc_class.define_method("update_advances?", method!(RbDoc::update_advances, 1))?;
371
407
  doc_class.define_method("sync_step1", method!(RbDoc::sync_step1, 0))?;
@@ -8,7 +8,7 @@ use yrs::encoding::read::{Cursor, Read};
8
8
  use yrs::sync::protocol::MessageReader;
9
9
  use yrs::sync::{Message, SyncMessage};
10
10
  use yrs::updates::decoder::{Decode, DecoderV1};
11
- use yrs::{Doc, ReadTxn, Transact};
11
+ use yrs::{Doc, ReadTxn, StateVector, Transact, Update, WriteTxn};
12
12
 
13
13
  /// Classify a frame: a non-zero code only for exactly one well-formed message
14
14
  /// that consumes the whole buffer (the codes are the match arms below).
@@ -66,14 +66,42 @@ pub(crate) fn merged_doc_update(bytes: &[u8]) -> Result<Option<Vec<u8>>, String>
66
66
  Ok(Some(merged))
67
67
  }
68
68
 
69
- /// True if applying `update_bytes` to `doc` would integrate cleanly: every
70
- /// dependency the update references is already present (the doc's state vector
71
- /// covers the update's lower bound). A pure read; does not mutate the doc.
72
- /// When false, applying it would park a pending struct, the signal that an
73
- /// earlier, causally-prior update is missing.
69
+ /// True if applying `update_bytes` to `doc` would integrate cleanly; false if
70
+ /// it would park as pending (a causally-prior update is missing). A pure read.
71
+ ///
72
+ /// This must be EXACT: the sync layer records on "ready" and resyncs on "not
73
+ /// ready", and a parked update that slipped through would look like an
74
+ /// already-applied retry downstream — acked and dropped, losing real content.
75
+ ///
76
+ /// Clocks alone can't decide it. An update can satisfy every per-client clock
77
+ /// and still fail to integrate: its items may reference other clients' blocks
78
+ /// (origins/parents), and merged updates hide internal gaps behind Skip blocks.
79
+ /// So the clock lower bound serves only as a cheap definitive REJECT; "ready"
80
+ /// is decided by trial-integrating on a throwaway probe seeded with the doc's
81
+ /// integrated state — ready iff nothing parks.
74
82
  pub(crate) fn update_is_ready(doc: &Doc, update_bytes: &[u8]) -> Result<bool, String> {
75
83
  let update = yrs::Update::decode_v1(update_bytes).map_err(|e| e.to_string())?;
76
- Ok(doc.transact().state_vector() >= update.state_vector_lower())
84
+ // Partial order: "not covered" includes incomparable — not ready either way.
85
+ let lower_covered = doc.transact().state_vector() >= update.state_vector_lower();
86
+ if !lower_covered {
87
+ return Ok(false);
88
+ }
89
+ // Seed the probe with the doc's INTEGRATED state (gap-free), for two
90
+ // reasons. A lossless seed would replant the doc's own pre-existing
91
+ // pending in the probe, making has_pending true for EVERY update — the
92
+ // verdict must be about this update, not the doc's baggage. And an update
93
+ // whose dependency exists only in that pending buffer is genuinely not
94
+ // ready: recording it would put a gap in the durable log; a resync heals
95
+ // both it and the pending it leans on as one complete delta.
96
+ let seed = integrated_update(doc, &StateVector::default())?;
97
+ let probe = Doc::new();
98
+ {
99
+ let mut txn = probe.transact_mut();
100
+ txn.apply_update(Update::decode_v1(&seed).map_err(|e| e.to_string())?)
101
+ .map_err(|e| e.to_string())?;
102
+ txn.apply_update(update).map_err(|e| e.to_string())?;
103
+ }
104
+ Ok(!has_pending(&probe))
77
105
  }
78
106
 
79
107
  /// True if applying `update_bytes` would actually change `doc`, i.e. it carries
@@ -107,6 +135,16 @@ pub(crate) fn update_advances_doc(doc: &Doc, update_bytes: &[u8]) -> Result<bool
107
135
  let update = yrs::Update::decode_v1(update_bytes).map_err(|e| e.to_string())?;
108
136
  let has_deletes = !update.delete_set().is_empty();
109
137
 
138
+ // Fast path: blocks beyond the doc's state vector are content the doc
139
+ // lacks — the update advances, no probe needed. The common case (a novel
140
+ // edit) exits here; only retries and ambiguous diffs pay for the probe.
141
+ if !has_deletes {
142
+ let covered = doc.transact().state_vector() >= update.state_vector();
143
+ if !covered {
144
+ return Ok(true);
145
+ }
146
+ }
147
+
110
148
  // Seed an independent probe with the doc's current state so we can measure the
111
149
  // update's effect without mutating the real doc.
112
150
  let probe = Doc::new();
@@ -117,6 +155,10 @@ pub(crate) fn update_advances_doc(doc: &Doc, update_bytes: &[u8]) -> Result<bool
117
155
  .transact_mut()
118
156
  .apply_update(yrs::Update::decode_v1(&current).map_err(|e| e.to_string())?)
119
157
  .map_err(|e| e.to_string())?;
158
+ let pending_before = {
159
+ probe.transact().store().pending_update().is_some()
160
+ || probe.transact().store().pending_ds().is_some()
161
+ };
120
162
 
121
163
  if has_deletes {
122
164
  // Deletes don't move the state vector; compare the full encoded state
@@ -139,25 +181,73 @@ pub(crate) fn update_advances_doc(doc: &Doc, update_bytes: &[u8]) -> Result<bool
139
181
  .apply_update(update)
140
182
  .map_err(|e| e.to_string())?;
141
183
  let after = probe.transact().state_vector();
142
- Ok(before != after)
184
+ if before != after {
185
+ return Ok(true);
186
+ }
187
+ // An unchanged state vector is ambiguous. Usually it means the doc
188
+ // already had everything in this update (a retry — return false, don't
189
+ // re-record). But it can ALSO mean the update failed to integrate and
190
+ // was stashed as pending, which doesn't move the state vector either.
191
+ // That case is missing content, not a duplicate — returning false would
192
+ // let a caller ack it and drop it. Distinguish the two by whether the
193
+ // probe gained pending. (The sync flow screens gaps out with
194
+ // update_is_ready before calling this; the check guards direct callers.)
195
+ let pending_after = probe.transact().store().pending_update().is_some()
196
+ || probe.transact().store().pending_ds().is_some();
197
+ Ok(pending_after != pending_before)
143
198
  }
144
199
  }
145
200
 
146
- /// True if the doc holds pending structs or a pending delete set: blocks that
147
- /// couldn't integrate because a dependency is missing. Test-only: asserts the
148
- /// causal-chain parking behavior in the unit tests below.
149
- #[cfg(test)]
150
- pub(crate) fn doc_has_pending(doc: &Doc) -> bool {
201
+ /// True if the doc holds un-integrable pending structs or a pending delete set:
202
+ /// blocks that couldn't integrate because a causally-prior update is missing. A
203
+ /// pure read; does not mutate.
204
+ pub(crate) fn has_pending(doc: &Doc) -> bool {
151
205
  let txn = doc.transact();
152
206
  txn.store().pending_update().is_some() || txn.store().pending_ds().is_some()
153
207
  }
154
208
 
209
+ /// Encode the doc's **integrated** state as a v1 update diffed against `sv`,
210
+ /// excluding any pending (un-integrable) structs and pending delete set.
211
+ ///
212
+ /// Pending blocks are a recovery buffer, not document state. Serving them across
213
+ /// the sync boundary hands a peer content it can't integrate, so the peer parks
214
+ /// the same pending forever and the state-vector/content mismatch drives endless
215
+ /// resync traffic. `encode_state_as_update_v1` merges pending back in (see yrs
216
+ /// `merge_pending_v1`), so to get a gap-free encode we rebuild the state into a
217
+ /// throwaway doc and `prune_pending` there before re-encoding.
218
+ ///
219
+ /// Non-destructive: the prune happens only on the throwaway copy; `doc` keeps its
220
+ /// pending, so a genuine gap still heals if its missing dependency later arrives.
221
+ pub(crate) fn integrated_update(doc: &Doc, sv: &StateVector) -> Result<Vec<u8>, String> {
222
+ // Pending check and encode share ONE transaction — with two, a concurrent
223
+ // gappy apply_update could slip between them and the encode would serve
224
+ // the very pending this function exists to exclude.
225
+ let full = {
226
+ let txn = doc.transact();
227
+ let store = txn.store();
228
+ // Nothing pending: the direct encode is already gap-free.
229
+ if store.pending_update().is_none() && store.pending_ds().is_none() {
230
+ return Ok(txn.encode_state_as_update_v1(sv));
231
+ }
232
+ txn.encode_state_as_update_v1(&StateVector::default())
233
+ };
234
+ let clean = Doc::new();
235
+ {
236
+ let mut txn = clean.transact_mut();
237
+ txn.apply_update(Update::decode_v1(&full).map_err(|e| e.to_string())?)
238
+ .map_err(|e| e.to_string())?;
239
+ txn.prune_pending();
240
+ }
241
+ let out = clean.transact().encode_state_as_update_v1(sv);
242
+ Ok(out)
243
+ }
244
+
155
245
  #[cfg(test)]
156
246
  mod tests {
157
247
  use super::*;
158
248
  use yrs::sync::Awareness;
159
249
  use yrs::updates::encoder::Encode;
160
- use yrs::Text;
250
+ use yrs::{GetString, Text};
161
251
 
162
252
  fn text_update(content: &str) -> Vec<u8> {
163
253
  let doc = Doc::new();
@@ -418,24 +508,405 @@ mod tests {
418
508
  !update_is_ready(&doc, u3).unwrap(),
419
509
  "u3 depends on the missing u2"
420
510
  );
421
- assert!(
422
- !doc_has_pending(&doc),
423
- "nothing pending until u3 is applied"
424
- );
511
+ assert!(!has_pending(&doc), "nothing pending until u3 is applied");
425
512
 
426
513
  // Applying u3 anyway parks it as a pending struct.
427
514
  doc.transact_mut()
428
515
  .apply_update(yrs::Update::decode_v1(u3).unwrap())
429
516
  .unwrap();
430
- assert!(
431
- doc_has_pending(&doc),
432
- "u3 is pending: its parent u2 is missing"
433
- );
517
+ assert!(has_pending(&doc), "u3 is pending: its parent u2 is missing");
434
518
 
435
519
  // Once u2 arrives (via resync), u3 integrates and pending clears.
436
520
  doc.transact_mut()
437
521
  .apply_update(yrs::Update::decode_v1(u2).unwrap())
438
522
  .unwrap();
439
- assert!(!doc_has_pending(&doc), "u2 arrived; u3 integrated");
523
+ assert!(!has_pending(&doc), "u2 arrived; u3 integrated");
524
+ }
525
+
526
+ // Build a cross-client-origin gap: client C creates "abc"; client A applies
527
+ // it and types between C's characters, so A's delta references C's blocks as
528
+ // origins. Returns (c_update, a_delta). On a doc missing `c_update`, the
529
+ // per-client clock lower bound of `a_delta` is satisfied (A starts at clock
530
+ // 0) but integration parks — the case a clock-only readiness check misses.
531
+ fn cross_client_origin_gap() -> (Vec<u8>, Vec<u8>) {
532
+ let c = Doc::new();
533
+ let ct = c.get_or_insert_text("t");
534
+ ct.insert(&mut c.transact_mut(), 0, "abc");
535
+ let c_update = c
536
+ .transact()
537
+ .encode_state_as_update_v1(&yrs::StateVector::default());
538
+
539
+ let a = Doc::new();
540
+ a.transact_mut()
541
+ .apply_update(yrs::Update::decode_v1(&c_update).unwrap())
542
+ .unwrap();
543
+ let sv_before = a.transact().state_vector();
544
+ let at = a.get_or_insert_text("t");
545
+ at.insert(&mut a.transact_mut(), 1, "X"); // between C's chars
546
+ let a_delta = a.transact().encode_state_as_update_v1(&sv_before);
547
+ (c_update, a_delta)
548
+ }
549
+
550
+ #[test]
551
+ fn cross_client_origin_gap_is_not_ready() {
552
+ let (c_update, a_delta) = cross_client_origin_gap();
553
+
554
+ // A server that never saw C's content: the clock lower bound passes, but
555
+ // the update can't integrate — it must NOT be ready (previously it was,
556
+ // and the downstream advances? probe then acked-and-dropped it).
557
+ let server = Doc::new();
558
+ assert!(
559
+ !update_is_ready(&server, &a_delta).unwrap(),
560
+ "a delta with unmet cross-client origins is not ready"
561
+ );
562
+
563
+ // Once the server has C's content, the same delta is ready and advances.
564
+ server
565
+ .transact_mut()
566
+ .apply_update(yrs::Update::decode_v1(&c_update).unwrap())
567
+ .unwrap();
568
+ assert!(update_is_ready(&server, &a_delta).unwrap());
569
+ assert!(update_advances_doc(&server, &a_delta).unwrap());
570
+ }
571
+
572
+ #[test]
573
+ fn merged_update_with_internal_skip_gap_is_not_ready() {
574
+ // Merging u1 and u3 (u2 missing) yields one update with a Skip block; its
575
+ // clock lower bound is u1's start, but the post-Skip blocks can't
576
+ // integrate on a doc that lacks u2.
577
+ let src = Doc::new();
578
+ let txt = src.get_or_insert_text("t");
579
+ let mut deltas: Vec<Vec<u8>> = Vec::new();
580
+ let mut prev = yrs::StateVector::default();
581
+ for (i, ch) in ["A", "B", "C"].into_iter().enumerate() {
582
+ txt.insert(&mut src.transact_mut(), i as u32, ch);
583
+ deltas.push(src.transact().encode_state_as_update_v1(&prev));
584
+ prev = src.transact().state_vector();
585
+ }
586
+ let merged = yrs::merge_updates_v1([deltas[0].as_slice(), deltas[2].as_slice()]).unwrap();
587
+
588
+ let server = Doc::new();
589
+ assert!(
590
+ !update_is_ready(&server, &merged).unwrap(),
591
+ "the post-Skip blocks depend on the missing u2"
592
+ );
593
+ }
594
+
595
+ #[test]
596
+ fn a_doc_with_legacy_pending_still_accepts_healthy_updates() {
597
+ // Why update_is_ready seeds its probe with the INTEGRATED state: with a
598
+ // lossless seed, the doc's own pre-existing pending would park in the
599
+ // probe and every verdict would come back "not ready" — a server with
600
+ // one legacy gap would reject every healthy keystroke forever.
601
+ let (_first, dependent) = gap_pair();
602
+ let doc = Doc::new();
603
+ doc.transact_mut()
604
+ .apply_update(yrs::Update::decode_v1(&dependent).unwrap())
605
+ .unwrap();
606
+ assert!(has_pending(&doc), "the doc carries a legacy parked gap");
607
+
608
+ // A healthy, self-contained update from an unrelated client.
609
+ let healthy = {
610
+ let d = Doc::new();
611
+ let t = d.get_or_insert_text("other");
612
+ t.insert(&mut d.transact_mut(), 0, "hello");
613
+ let txn = d.transact();
614
+ txn.encode_state_as_update_v1(&yrs::StateVector::default())
615
+ };
616
+
617
+ assert!(
618
+ update_is_ready(&doc, &healthy).unwrap(),
619
+ "legacy pending must not veto unrelated healthy updates"
620
+ );
621
+ assert!(update_advances_doc(&doc, &healthy).unwrap());
622
+ }
623
+
624
+ #[test]
625
+ fn an_update_depending_only_on_pending_content_is_not_ready() {
626
+ // The other half of the integrated-only seed: a dependency that exists
627
+ // solely in the doc's pending buffer doesn't count — recording such an
628
+ // update would put a gap in the durable log. Not ready; resync heals
629
+ // both as one complete delta.
630
+ let src = Doc::new();
631
+ let txt = src.get_or_insert_text("t");
632
+ let mut deltas: Vec<Vec<u8>> = Vec::new();
633
+ let mut prev = yrs::StateVector::default();
634
+ for (i, ch) in ["A", "B", "C"].into_iter().enumerate() {
635
+ txt.insert(&mut src.transact_mut(), i as u32, ch);
636
+ deltas.push(src.transact().encode_state_as_update_v1(&prev));
637
+ prev = src.transact().state_vector();
638
+ }
639
+
640
+ // The doc holds u2 only as PENDING (u1 never arrived); u3 depends on u2.
641
+ let doc = Doc::new();
642
+ doc.transact_mut()
643
+ .apply_update(yrs::Update::decode_v1(&deltas[1]).unwrap())
644
+ .unwrap();
645
+ assert!(has_pending(&doc), "u2 parked without u1");
646
+
647
+ assert!(
648
+ !update_is_ready(&doc, &deltas[2]).unwrap(),
649
+ "a dependency satisfied only by pending content is not ready"
650
+ );
651
+ }
652
+
653
+ #[test]
654
+ fn update_advances_reports_true_when_the_update_would_park() {
655
+ // Defense in depth for callers using advances? without the ready gate: a
656
+ // gappy update parks pending — that changes the doc, so it advances (it
657
+ // must never be misread as an already-applied retry and dropped).
658
+ let (_c_update, a_delta) = cross_client_origin_gap();
659
+ let server = Doc::new();
660
+ assert!(
661
+ update_advances_doc(&server, &a_delta).unwrap(),
662
+ "a parked update is not a duplicate"
663
+ );
664
+ }
665
+
666
+ // Build a causal gap: `first` inserts "a", `dependent` inserts "b" after it,
667
+ // so `dependent` alone parks as pending on a doc that lacks `first`.
668
+ fn gap_pair() -> (Vec<u8>, Vec<u8>) {
669
+ let src = Doc::new();
670
+ let txt = src.get_or_insert_text("notepad");
671
+ txt.insert(&mut src.transact_mut(), 0, "a");
672
+ let first = src
673
+ .transact()
674
+ .encode_state_as_update_v1(&yrs::StateVector::default());
675
+ let sv = src.transact().state_vector();
676
+ txt.insert(&mut src.transact_mut(), 1, "b");
677
+ let dependent = src.transact().encode_state_as_update_v1(&sv);
678
+ (first, dependent)
679
+ }
680
+
681
+ #[test]
682
+ fn integrated_update_strips_pending_and_is_non_destructive() {
683
+ let (_first, dependent) = gap_pair();
684
+ let doc = Doc::new();
685
+ doc.transact_mut()
686
+ .apply_update(yrs::Update::decode_v1(&dependent).unwrap())
687
+ .unwrap();
688
+ assert!(has_pending(&doc), "the gappy update parked as pending");
689
+
690
+ // encode_state_as_update carries the pending; integrated_update does not.
691
+ let full = doc
692
+ .transact()
693
+ .encode_state_as_update_v1(&yrs::StateVector::default());
694
+ let gap_free = integrated_update(&doc, &yrs::StateVector::default()).unwrap();
695
+ assert_ne!(full, gap_free, "integrated_update drops the pending bytes");
696
+
697
+ // Applying the gap-free encode to a fresh peer must NOT poison it.
698
+ let peer = Doc::new();
699
+ peer.transact_mut()
700
+ .apply_update(yrs::Update::decode_v1(&gap_free).unwrap())
701
+ .unwrap();
702
+ assert!(
703
+ !has_pending(&peer),
704
+ "peer got no pending from the gap-free state"
705
+ );
706
+
707
+ // Non-destructive: the source doc keeps its pending (so it can still heal).
708
+ assert!(
709
+ has_pending(&doc),
710
+ "integrated_update did not mutate the source"
711
+ );
712
+ }
713
+
714
+ #[test]
715
+ fn integrated_update_fast_path_matches_direct_encode_when_clean() {
716
+ // No pending -> byte-identical to encode_state_as_update (zero-copy path).
717
+ let (first, _dependent) = gap_pair();
718
+ let doc = Doc::new();
719
+ doc.transact_mut()
720
+ .apply_update(yrs::Update::decode_v1(&first).unwrap())
721
+ .unwrap();
722
+ assert!(!has_pending(&doc));
723
+ let direct = doc
724
+ .transact()
725
+ .encode_state_as_update_v1(&yrs::StateVector::default());
726
+ let via = integrated_update(&doc, &yrs::StateVector::default()).unwrap();
727
+ assert_eq!(direct, via);
728
+ }
729
+
730
+ #[test]
731
+ fn a_healed_gap_serves_its_content() {
732
+ // After the missing dependency arrives, the (formerly pending) content is
733
+ // integrated and integrated_update includes it.
734
+ let (first, dependent) = gap_pair();
735
+ let doc = Doc::new();
736
+ doc.transact_mut()
737
+ .apply_update(yrs::Update::decode_v1(&dependent).unwrap())
738
+ .unwrap();
739
+ doc.transact_mut()
740
+ .apply_update(yrs::Update::decode_v1(&first).unwrap())
741
+ .unwrap();
742
+ assert!(!has_pending(&doc), "gap healed once first arrived");
743
+ let gap_free = integrated_update(&doc, &yrs::StateVector::default()).unwrap();
744
+ let peer = Doc::new();
745
+ peer.transact_mut()
746
+ .apply_update(yrs::Update::decode_v1(&gap_free).unwrap())
747
+ .unwrap();
748
+ assert_eq!(
749
+ peer.get_or_insert_text("notepad")
750
+ .get_string(&peer.transact()),
751
+ "ab"
752
+ );
753
+ }
754
+
755
+ // A gappy insert from its own independent client: inserts two chars and
756
+ // returns only the second delta, which depends on the (missing) first.
757
+ fn independent_gappy_insert() -> Vec<u8> {
758
+ let src = Doc::new();
759
+ let txt = src.get_or_insert_text("notepad");
760
+ txt.insert(&mut src.transact_mut(), 0, "x");
761
+ let sv = src.transact().state_vector();
762
+ txt.insert(&mut src.transact_mut(), 1, "y");
763
+ let txn = src.transact();
764
+ txn.encode_state_as_update_v1(&sv)
765
+ }
766
+
767
+ #[test]
768
+ fn integrated_update_keeps_content_and_drops_pending_when_mixed() {
769
+ // The realistic case: a doc with real integrated content AND a pending
770
+ // struct. Pruning must keep the content and drop only the pending.
771
+ let (first, _dep) = gap_pair();
772
+ let doc = Doc::new();
773
+ doc.transact_mut()
774
+ .apply_update(yrs::Update::decode_v1(&first).unwrap())
775
+ .unwrap(); // integrated "a"
776
+ doc.transact_mut()
777
+ .apply_update(yrs::Update::decode_v1(&independent_gappy_insert()).unwrap())
778
+ .unwrap(); // + a pending struct from another client
779
+ assert!(has_pending(&doc));
780
+
781
+ let gap_free = integrated_update(&doc, &yrs::StateVector::default()).unwrap();
782
+ let peer = Doc::new();
783
+ peer.transact_mut()
784
+ .apply_update(yrs::Update::decode_v1(&gap_free).unwrap())
785
+ .unwrap();
786
+ assert_eq!(
787
+ peer.get_or_insert_text("notepad")
788
+ .get_string(&peer.transact()),
789
+ "a",
790
+ "kept the integrated content"
791
+ );
792
+ assert!(!has_pending(&peer), "dropped the pending");
793
+ }
794
+
795
+ #[test]
796
+ fn integrated_update_diffs_against_a_peer_sv_and_excludes_pending() {
797
+ // The production signature: `handle_sync_message` calls
798
+ // integrated_update(doc, peer_sv). A peer already holding the integrated
799
+ // content should get a diff carrying no new content and no pending.
800
+ let (first, _dep) = gap_pair();
801
+ let server = Doc::new();
802
+ server
803
+ .transact_mut()
804
+ .apply_update(yrs::Update::decode_v1(&first).unwrap())
805
+ .unwrap();
806
+ server
807
+ .transact_mut()
808
+ .apply_update(yrs::Update::decode_v1(&independent_gappy_insert()).unwrap())
809
+ .unwrap();
810
+
811
+ let peer = Doc::new();
812
+ peer.transact_mut()
813
+ .apply_update(yrs::Update::decode_v1(&first).unwrap())
814
+ .unwrap();
815
+ let peer_sv = peer.transact().state_vector();
816
+
817
+ let diff = integrated_update(&server, &peer_sv).unwrap();
818
+ peer.transact_mut()
819
+ .apply_update(yrs::Update::decode_v1(&diff).unwrap())
820
+ .unwrap();
821
+ assert_eq!(
822
+ peer.get_or_insert_text("notepad")
823
+ .get_string(&peer.transact()),
824
+ "a"
825
+ );
826
+ assert!(!has_pending(&peer), "the diff carried no pending");
827
+ }
828
+
829
+ #[test]
830
+ fn integrated_update_never_serves_pending_under_concurrent_gappy_applies() {
831
+ // Invariant under contention: while a writer parks and heals a gappy
832
+ // update in a loop, every integrated_update encode must be pending-free
833
+ // for a fresh peer.
834
+ //
835
+ // Scope: this can't hit the original check-vs-encode race (its window
836
+ // is nanoseconds; never reproduced even at 20k iterations) — that fix
837
+ // is guaranteed by using a single transaction. What this catches is
838
+ // coarser: encoding outside the lock, or a fast path skipping the
839
+ // pending check.
840
+ use std::sync::atomic::{AtomicBool, Ordering};
841
+ use std::sync::Arc as StdArc;
842
+
843
+ let (first, dependent) = gap_pair();
844
+ let doc = StdArc::new(Doc::new());
845
+ let stop = StdArc::new(AtomicBool::new(false));
846
+
847
+ let writer = {
848
+ let doc = StdArc::clone(&doc);
849
+ let stop = StdArc::clone(&stop);
850
+ let dependent = dependent.clone();
851
+ let first = first.clone();
852
+ std::thread::spawn(move || {
853
+ while !stop.load(Ordering::Relaxed) {
854
+ // Park a pending struct, then heal it, over and over — the
855
+ // encode below keeps racing both transitions.
856
+ doc.transact_mut()
857
+ .apply_update(yrs::Update::decode_v1(&dependent).unwrap())
858
+ .unwrap();
859
+ doc.transact_mut()
860
+ .apply_update(yrs::Update::decode_v1(&first).unwrap())
861
+ .unwrap();
862
+ }
863
+ })
864
+ };
865
+
866
+ for _ in 0..500 {
867
+ let encoded = integrated_update(&doc, &yrs::StateVector::default()).unwrap();
868
+ let peer = Doc::new();
869
+ peer.transact_mut()
870
+ .apply_update(yrs::Update::decode_v1(&encoded).unwrap())
871
+ .unwrap();
872
+ assert!(
873
+ !has_pending(&peer),
874
+ "an integrated_update encode leaked pending to a peer"
875
+ );
876
+ }
877
+ stop.store(true, Ordering::Relaxed);
878
+ writer.join().unwrap();
879
+ }
880
+
881
+ #[test]
882
+ fn integrated_update_strips_a_pending_delete_set() {
883
+ // A deletion whose target struct is absent parks as a pending *delete
884
+ // set* -- the delete-side counterpart to a pending struct.
885
+ let src = Doc::new();
886
+ let txt = src.get_or_insert_text("notepad");
887
+ txt.insert(&mut src.transact_mut(), 0, "z");
888
+ let sv = src.transact().state_vector();
889
+ txt.remove_range(&mut src.transact_mut(), 0, 1);
890
+ let deletion = src.transact().encode_state_as_update_v1(&sv); // delete-only
891
+
892
+ let doc = Doc::new();
893
+ doc.transact_mut()
894
+ .apply_update(yrs::Update::decode_v1(&deletion).unwrap())
895
+ .unwrap();
896
+ assert!(
897
+ has_pending(&doc),
898
+ "the orphan deletion parked as a pending delete set"
899
+ );
900
+
901
+ let gap_free = integrated_update(&doc, &yrs::StateVector::default()).unwrap();
902
+ let peer = Doc::new();
903
+ peer.transact_mut()
904
+ .apply_update(yrs::Update::decode_v1(&gap_free).unwrap())
905
+ .unwrap();
906
+ assert!(!has_pending(&peer), "the pending delete set was not served");
907
+ assert!(
908
+ has_pending(&doc),
909
+ "non-destructive: source keeps its pending"
910
+ );
440
911
  }
441
912
  }