yrb-lite 0.1.0.beta2 → 0.1.0.beta3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e81bc59c4870f1c86a73f06fadeaf00fe60b46f0677823d2a6de20190761632e
4
- data.tar.gz: bf98a231bccd388df0068af60a336c110fdc043daf3cc6cfa56208a4de6352db
3
+ metadata.gz: fdc68c7b4936a2e6598441f2def20050e456a543f44de6ecd35aeb1a894cdcc7
4
+ data.tar.gz: 5a5ffbf2c1df1fc41d7eba424579459587d46e1e38db0606b79efefb5d5321c0
5
5
  SHA512:
6
- metadata.gz: 2e07b28a11faa8ec051b78eb107fa9d4f63fbd483b1118fd7605632665773525cb07f147f3dc25136b3876976b8d9a470db5ff5277a8647cffc64efb8bf4b298
7
- data.tar.gz: b1afad6a62b1dd0d1edfa7c8bffc5e324d407c489c2ff9fc4056c43d8af0f076ec30fdcf946582ee092b642210414912baa39be279770a88f78279c2ec707127
6
+ metadata.gz: 00bff87ec9b51a46f7bee55e838ef629531c0f4faaa43400f2447055932fefc4d8ce597c15724b98e384bbd5f119c881ebf1f85f2b2bab614ff6d3e00d570eaa
7
+ data.tar.gz: 0dff053de27327d7517bdb83b850ee45c977d38f1556ee38210e8e3431da19937c1cc4e22201da2242ca249cee1a1647b8f0ec5dd1fe50ed75fb2d78743a4947
data/CHANGELOG.md CHANGED
@@ -6,6 +6,28 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.0.beta3] - 2026-06-18
10
+
11
+ ### Changed
12
+
13
+ - Upgraded the bundled `yrs` (y-crdt) from 0.21 to 0.27.2. No change to the
14
+ `YrbLite::Doc`, `YrbLite::Awareness`, or `YrbLite::Sync` public API; existing
15
+ code and the wire protocol are unaffected.
16
+ - Thread-safety is preserved across the upgrade. yrs 0.27 dropped `Awareness`'s
17
+ internal locking (its mutating methods now take `&mut self`, and `Awareness`
18
+ is no longer `Sync`), so `YrbLite::Awareness` now serializes access through an
19
+ internal `Mutex`. The lock is taken only while the GVL is released and is
20
+ never held across the GVL boundary, so concurrent access from multiple Ruby
21
+ threads stays safe and deadlock-free, and document reads still run in parallel
22
+ (they operate on a cheaply-cloned, `Arc`-backed `Doc` handle, not under the
23
+ presence lock).
24
+
25
+ ### Build
26
+
27
+ - Building the gem from source now requires **Rust 1.94 or newer** (yrs 0.27.2
28
+ uses `let`-chains). The precompiled platform gems are unaffected -- they need
29
+ no Rust toolchain to install.
30
+
9
31
  ## [0.1.0.beta2] - 2026-06-16
10
32
 
11
33
  ### Added
@@ -54,6 +76,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
54
76
  - Precompiled native gems for common platforms (no Rust toolchain needed to
55
77
  install) via the cross-gem workflow.
56
78
 
57
- [Unreleased]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta2...main
79
+ [Unreleased]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta3...main
80
+ [0.1.0.beta3]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta2...v0.1.0.beta3
58
81
  [0.1.0.beta2]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta1...v0.1.0.beta2
59
82
  [0.1.0.beta1]: https://github.com/jpcamara/yrb-lite/releases/tag/v0.1.0.beta1
@@ -12,7 +12,7 @@ crate-type = ["cdylib"]
12
12
  [dependencies]
13
13
  magnus = "0.8"
14
14
  rb-sys = "0.9"
15
- yrs = { version = "0.21", features = ["sync"] }
15
+ yrs = { version = "0.27", features = ["sync"] }
16
16
  serde_json = "1.0"
17
17
 
18
18
  [dev-dependencies]
@@ -4,7 +4,8 @@ use yrs::sync::protocol::MessageReader;
4
4
  use yrs::sync::{Awareness, DefaultProtocol, Message, Protocol, SyncMessage};
5
5
  use yrs::updates::decoder::{Decode, DecoderV1};
6
6
  use yrs::updates::encoder::{Encode, Encoder, EncoderV1};
7
- use yrs::{Doc, ReadTxn, Transact};
7
+ use std::sync::Mutex;
8
+ use yrs::{ClientID, Doc, ReadTxn, Transact};
8
9
 
9
10
  /// Wrapper around yrs Doc.
10
11
  ///
@@ -18,20 +19,35 @@ struct RbDoc(Doc);
18
19
 
19
20
  /// Wrapper around yrs Awareness (which contains a Doc).
20
21
  ///
21
- /// Thread safety: `yrs::sync::Awareness` keeps client states in a `DashMap`
22
- /// and exposes everything through `&self`, so it's built for multi-threaded
23
- /// server use.
22
+ /// Thread safety: as of yrs 0.27 `Awareness` dropped its internal locking and
23
+ /// its mutating methods (`handle`, `set_local_state`, `clean_local_state`,
24
+ /// `remove_state`, `update_with_clients`) take `&mut self`. It is `Send` but no
25
+ /// longer `Sync`, so we serialize all access through a `Mutex`.
26
+ ///
27
+ /// CRITICAL: the `Mutex` is ALWAYS locked inside the `nogvl` closure (never with
28
+ /// the GVL held) and the guard is dropped before the closure returns. This obeys
29
+ /// the same rule as the doc's RwLock (see `nogvl`): a thread never waits on this
30
+ /// lock while holding the GVL, and never reacquires the GVL while holding this
31
+ /// lock, so the GVL and this `Mutex` can't deadlock on lock order. Locking with
32
+ /// the GVL held (outside `nogvl`) reintroduces that deadlock -- don't.
33
+ ///
34
+ /// For doc-only reads we clone the (Arc-backed) `Doc` out under the brief lock
35
+ /// and operate on the owned clone, so a long encode holds only the doc's own
36
+ /// RwLock, not this `Mutex`, and never blocks presence updates on another
37
+ /// thread. Lock order is always Mutex-then-doc-RwLock (or doc-RwLock alone),
38
+ /// never the reverse.
24
39
  #[magnus::wrap(class = "YrbLite::Awareness", free_immediately, size)]
25
- struct RbAwareness(Awareness);
40
+ struct RbAwareness(Mutex<Awareness>);
26
41
 
27
42
  /// Compile-time proof that the wrapped types are thread-safe. If a future
28
- /// yrs upgrade makes Doc or Awareness lose Send/Sync, this fails the build
29
- /// instead of silently shipping a thread-unsafe gem.
43
+ /// yrs upgrade makes Doc lose Send/Sync, or Awareness lose Send, this fails the
44
+ /// build instead of silently shipping a thread-unsafe gem. (Awareness is no
45
+ /// longer `Sync` as of yrs 0.27, hence the `Mutex` wrapper, which restores it.)
30
46
  #[allow(dead_code)]
31
47
  fn assert_thread_safe() {
32
48
  fn is_send_sync<T: Send + Sync>() {}
33
49
  is_send_sync::<Doc>();
34
- is_send_sync::<Awareness>();
50
+ is_send_sync::<Mutex<Awareness>>();
35
51
  }
36
52
 
37
53
  /// Run `f` with the GVL (Global VM Lock) released, so other Ruby threads,
@@ -42,10 +58,13 @@ fn assert_thread_safe() {
42
58
  /// out of Ruby strings before entering, and results are converted to Ruby
43
59
  /// objects after returning.
44
60
  /// - It must be `Send` (it runs while other threads own the GVL). `&Doc` and
45
- /// `&Awareness` are fine: both types are `Sync` (asserted above).
46
- /// - Any doc lock it takes must be acquired and released inside the closure, so
47
- /// we never reacquire the GVL while holding a yrs lock and can't deadlock on
48
- /// lock order.
61
+ /// `&Mutex<Awareness>` are fine: both are `Sync` (asserted above).
62
+ /// - LOCK DISCIPLINE: any native lock it takes -- the doc's internal RwLock OR
63
+ /// the awareness `Mutex` (`self.0.lock()`) -- must be acquired AND released
64
+ /// inside this closure (GVL already dropped). Never lock with the GVL held
65
+ /// (e.g. before calling `nogvl`), or a thread waiting on the lock while
66
+ /// holding the GVL can deadlock against the GVL reacquire. Same reason we
67
+ /// never hold a lock across the GVL boundary.
49
68
  ///
50
69
  /// Panics inside the closure are caught and re-raised (resumed) after the GVL
51
70
  /// is reacquired, where magnus converts them to Ruby exceptions.
@@ -153,8 +172,17 @@ fn merged_doc_update(bytes: &[u8]) -> Result<Option<Vec<u8>>, String> {
153
172
  _ => yrs::merge_updates_v1(&updates).map_err(|e| e.to_string())?,
154
173
  };
155
174
  let update = yrs::Update::decode_v1(&merged).map_err(|e| e.to_string())?;
156
- if update.state_vector().is_empty() && update.delete_set().is_empty() {
157
- return Ok(None); // no-op (e.g. the empty SyncStep2 in an opening handshake)
175
+ // A genuine no-op (e.g. the empty SyncStep2 in an opening handshake) carries
176
+ // no structs, no deletes, and no dependencies. We must NOT treat a causally-
177
+ // pending update as a no-op: since yrs 0.26 such an update reports an empty
178
+ // state_vector (its structs can't integrate yet), but it still carries
179
+ // content and a non-empty lower bound (the deps it's waiting on). Dropping it
180
+ // here would silently swallow a gappy update instead of rejecting + resyncing.
181
+ if update.state_vector().is_empty()
182
+ && update.delete_set().is_empty()
183
+ && update.state_vector_lower().is_empty()
184
+ {
185
+ return Ok(None);
158
186
  }
159
187
  Ok(Some(merged))
160
188
  }
@@ -165,7 +193,7 @@ fn awareness_client_ids_in(bytes: &[u8]) -> Result<Vec<u64>, String> {
165
193
  let mut ids = Vec::new();
166
194
  for msg in MessageReader::new(&mut decoder) {
167
195
  if let Message::Awareness(update) = msg.map_err(|e| e.to_string())? {
168
- ids.extend(update.clients.keys().copied());
196
+ ids.extend(update.clients.keys().map(|c| c.get()));
169
197
  }
170
198
  }
171
199
  Ok(ids)
@@ -207,7 +235,7 @@ impl RbDoc {
207
235
 
208
236
  /// Get the client ID
209
237
  fn client_id(&self) -> u64 {
210
- self.0.client_id()
238
+ self.0.client_id().get()
211
239
  }
212
240
 
213
241
  /// Get the document GUID
@@ -367,17 +395,19 @@ impl RbAwareness {
367
395
  let client_id: u64 = TryConvert::try_convert(args[0])?;
368
396
  Awareness::new(Doc::with_client_id(client_id))
369
397
  };
370
- Ok(RbAwareness(awareness))
398
+ Ok(RbAwareness(Mutex::new(awareness)))
371
399
  }
372
400
 
373
401
  /// Get the client ID of the underlying document
374
402
  fn client_id(&self) -> u64 {
375
- self.0.doc().client_id()
403
+ let awareness = &self.0;
404
+ nogvl(move || awareness.lock().unwrap().doc().client_id().get())
376
405
  }
377
406
 
378
407
  /// Get the document GUID
379
408
  fn guid(&self) -> String {
380
- self.0.doc().guid().to_string()
409
+ let awareness = &self.0;
410
+ nogvl(move || awareness.lock().unwrap().doc().guid().to_string())
381
411
  }
382
412
 
383
413
  /// A standalone SyncStep1 message (the server's state vector). Sent as its
@@ -386,7 +416,8 @@ impl RbAwareness {
386
416
  fn sync_step1(&self) -> RString {
387
417
  let awareness = &self.0;
388
418
  let encoded = nogvl(move || {
389
- let txn = awareness.doc().transact();
419
+ let doc = awareness.lock().unwrap().doc().clone();
420
+ let txn = doc.transact();
390
421
  let sv = txn.state_vector();
391
422
  Message::Sync(SyncMessage::SyncStep1(sv)).encode_v1()
392
423
  });
@@ -398,10 +429,11 @@ impl RbAwareness {
398
429
  fn start(&self) -> Result<RString, Error> {
399
430
  let awareness = &self.0;
400
431
  let encoded = nogvl(move || -> Result<Vec<u8>, String> {
432
+ let awareness = awareness.lock().unwrap();
401
433
  let protocol = DefaultProtocol;
402
434
  let mut encoder = EncoderV1::new();
403
435
  protocol
404
- .start(awareness, &mut encoder)
436
+ .start(&awareness, &mut encoder)
405
437
  .map_err(|e| e.to_string())?;
406
438
  Ok(encoder.to_vec())
407
439
  })
@@ -416,9 +448,10 @@ impl RbAwareness {
416
448
  let awareness = &self.0;
417
449
 
418
450
  let encoded = nogvl(move || -> Result<Vec<u8>, String> {
451
+ let mut awareness = awareness.lock().unwrap();
419
452
  let protocol = DefaultProtocol;
420
453
  let responses = protocol
421
- .handle(awareness, &data_bytes)
454
+ .handle(&mut awareness, &data_bytes)
422
455
  .map_err(|e| e.to_string())?;
423
456
 
424
457
  if responses.is_empty() {
@@ -446,7 +479,8 @@ impl RbAwareness {
446
479
  fn encode_state_vector(&self) -> RString {
447
480
  let awareness = &self.0;
448
481
  let sv = nogvl(move || {
449
- let txn = awareness.doc().transact();
482
+ let doc = awareness.lock().unwrap().doc().clone();
483
+ let txn = doc.transact();
450
484
  txn.state_vector().encode_v1()
451
485
  });
452
486
  binary_string(&sv)
@@ -466,7 +500,8 @@ impl RbAwareness {
466
500
  None => yrs::StateVector::default(),
467
501
  Some(bytes) => yrs::StateVector::decode_v1(bytes).map_err(|e| e.to_string())?,
468
502
  };
469
- let txn = awareness.doc().transact();
503
+ let doc = awareness.lock().unwrap().doc().clone();
504
+ let txn = doc.transact();
470
505
  Ok(txn.encode_state_as_update_v1(&sv))
471
506
  })
472
507
  .map_err(runtime_error)?;
@@ -475,37 +510,47 @@ impl RbAwareness {
475
510
 
476
511
  /// Set local awareness state (JSON string)
477
512
  fn set_local_state(&self, json: String) -> Result<(), Error> {
478
- let awareness = &self.0;
479
513
  let value: serde_json::Value =
480
514
  serde_json::from_str(&json).map_err(|e| runtime_error(e.to_string()))?;
481
- awareness
482
- .set_local_state(value)
483
- .map_err(|e| runtime_error(e.to_string()))?;
484
- Ok(())
515
+ let awareness = &self.0;
516
+ nogvl(move || -> Result<(), String> {
517
+ awareness
518
+ .lock()
519
+ .unwrap()
520
+ .set_local_state(value)
521
+ .map_err(|e| e.to_string())
522
+ })
523
+ .map_err(runtime_error)
485
524
  }
486
525
 
487
526
  /// Get local awareness state as JSON string (or nil if not set)
488
527
  fn local_state(&self) -> Option<String> {
489
528
  let awareness = &self.0;
490
- awareness
491
- .local_state::<serde_json::Value>()
492
- .map(|v| v.to_string())
529
+ nogvl(move || {
530
+ awareness
531
+ .lock()
532
+ .unwrap()
533
+ .local_state::<serde_json::Value>()
534
+ .map(|v| v.to_string())
535
+ })
493
536
  }
494
537
 
495
538
  /// Clear local awareness state
496
539
  fn clear_local_state(&self) {
497
540
  let awareness = &self.0;
498
- awareness.clean_local_state();
541
+ nogvl(move || awareness.lock().unwrap().clean_local_state());
499
542
  }
500
543
 
501
544
  /// Get awareness update for broadcasting to peers
502
545
  fn encode_awareness_update(&self) -> Result<RString, Error> {
503
546
  let awareness = &self.0;
504
- let update = awareness
505
- .update()
506
- .map_err(|e| runtime_error(e.to_string()))?;
507
- let msg = Message::Awareness(update);
508
- Ok(binary_string(&msg.encode_v1()))
547
+ let encoded = nogvl(move || -> Result<Vec<u8>, String> {
548
+ let awareness = awareness.lock().unwrap();
549
+ let update = awareness.update().map_err(|e| e.to_string())?;
550
+ Ok(Message::Awareness(update).encode_v1())
551
+ })
552
+ .map_err(runtime_error)?;
553
+ Ok(binary_string(&encoded))
509
554
  }
510
555
 
511
556
  /// Apply a raw update to the underlying document
@@ -514,7 +559,8 @@ impl RbAwareness {
514
559
  let awareness = &self.0;
515
560
  nogvl(move || -> Result<(), String> {
516
561
  let update = yrs::Update::decode_v1(&update_bytes).map_err(|e| e.to_string())?;
517
- let mut txn = awareness.doc().transact_mut();
562
+ let doc = awareness.lock().unwrap().doc().clone();
563
+ let mut txn = doc.transact_mut();
518
564
  txn.apply_update(update).map_err(|e| e.to_string())
519
565
  })
520
566
  .map_err(runtime_error)
@@ -525,15 +571,22 @@ impl RbAwareness {
525
571
  /// Pure read; does not mutate.
526
572
  fn update_ready(&self, update: RString) -> Result<bool, Error> {
527
573
  let update_bytes = copy_bytes(update);
528
- let doc = self.0.doc();
529
- nogvl(move || update_is_ready(doc, &update_bytes)).map_err(runtime_error)
574
+ let awareness = &self.0;
575
+ nogvl(move || {
576
+ let doc = awareness.lock().unwrap().doc().clone();
577
+ update_is_ready(&doc, &update_bytes)
578
+ })
579
+ .map_err(runtime_error)
530
580
  }
531
581
 
532
582
  /// True if the document holds pending (un-integrable) structs waiting on a
533
583
  /// missing dependency.
534
584
  fn pending(&self) -> bool {
535
- let doc = self.0.doc();
536
- nogvl(move || doc_has_pending(doc))
585
+ let awareness = &self.0;
586
+ nogvl(move || {
587
+ let doc = awareness.lock().unwrap().doc().clone();
588
+ doc_has_pending(&doc)
589
+ })
537
590
  }
538
591
 
539
592
  /// Decode the awareness client IDs referenced by a protocol message
@@ -580,11 +633,13 @@ impl RbAwareness {
580
633
  fn remove_clients(&self, client_ids: Vec<u64>) -> Result<RString, Error> {
581
634
  let awareness = &self.0;
582
635
  let encoded = nogvl(move || -> Result<Vec<u8>, String> {
636
+ let mut awareness = awareness.lock().unwrap();
583
637
  let mut removed = Vec::new();
584
638
  for id in client_ids {
585
- if awareness.meta(id).is_some() {
586
- awareness.remove_state(id);
587
- removed.push(id);
639
+ let cid = ClientID::new(id);
640
+ if awareness.meta(cid).is_some() {
641
+ awareness.remove_state(cid);
642
+ removed.push(cid);
588
643
  }
589
644
  }
590
645
  if removed.is_empty() {
@@ -721,7 +776,7 @@ mod tests {
721
776
  }
722
777
 
723
778
  fn awareness_frame(client_id: u64) -> Vec<u8> {
724
- let awareness = Awareness::new(Doc::with_client_id(client_id));
779
+ let mut awareness = Awareness::new(Doc::with_client_id(client_id));
725
780
  awareness
726
781
  .set_local_state(serde_json::json!({ "user": "alice" }))
727
782
  .unwrap();
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module YrbLite
4
- VERSION = "0.1.0.beta2"
4
+ VERSION = "0.1.0.beta3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yrb-lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.beta2
4
+ version: 0.1.0.beta3
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara