1use std::collections::HashMap;
16use std::fmt;
17use std::marker::PhantomData;
18use std::str::FromStr;
19use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering};
20use std::sync::{Arc, LazyLock, Mutex, MutexGuard, Weak};
21
22use orbit_core::Fleet;
23#[cfg(unix)]
24use orbit_core::shm::{ShmRegion, ring_segment_name};
25
26mod text;
27
28pub use text::{CELL_TEXT_CAPACITY, CELL_TEXT_KIND, CELL_TEXT_MAX, Text, TextId};
29
30pub const CELL_STATE_KIND: u8 = 233;
32pub const CELL_CAPACITY: usize =
38 orbit_core::compile::usize_from_env(option_env!("ORBIT_CELL_CAPACITY"), 4_096);
39
40pub(crate) const SLOT_EMPTY: u8 = 0;
41pub(crate) const SLOT_OCCUPIED: u8 = 1;
42
43#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))]
46pub(crate) fn wait_on(word: &AtomicU32, expected: u32) -> Result<()> {
47 match orbit_core::sync::wait_word(word, expected) {
48 Ok(()) => Ok(()),
49 Err(error) if error.kind() == std::io::ErrorKind::Unsupported => {
50 std::thread::sleep(std::time::Duration::from_millis(1));
51 Ok(())
52 }
53 Err(error) => Err(Error::Io(error)),
54 }
55}
56
57#[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "macos")))]
58pub(crate) fn wait_on(_word: &AtomicU32, _expected: u32) -> Result<()> {
59 std::thread::sleep(std::time::Duration::from_millis(1));
60 Ok(())
61}
62
63pub(crate) fn wake_on(word: &AtomicU32) {
65 #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))]
66 let _ = orbit_core::sync::wake_word(word);
67 #[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "macos")))]
68 let _ = word;
69}
70
71#[cfg(unix)]
72const STATE_MAGIC: u32 = 0x43_45_4C_4C; #[cfg(unix)]
74const STATE_VERSION: u16 = 2;
75
76pub type Result<T, E = Error> = std::result::Result<T, E>;
77
78#[derive(Debug)]
79pub enum Error {
80 Stale(CellId),
83 StaleText(TextId),
85 TooLong {
87 len: usize,
88 max: usize,
89 },
90 Corrupt(String),
93 TypeMismatch {
95 id: CellId,
96 expected: &'static str,
97 found: &'static str,
98 },
99 Overflow,
101 Full {
103 capacity: usize,
104 },
105 Malformed(String),
107 Io(std::io::Error),
108}
109
110impl fmt::Display for Error {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 match self {
113 Self::Stale(id) => write!(f, "cell {id} has been released"),
114 Self::StaleText(id) => write!(f, "text cell {id} has been released"),
115 Self::TooLong { len, max } => {
116 write!(f, "text does not fit the cell: len={len} max={max}")
117 }
118 Self::Corrupt(id) => write!(f, "text cell {id} holds bytes that are not UTF-8"),
119 Self::TypeMismatch {
120 id,
121 expected,
122 found,
123 } => write!(f, "cell {id} holds {found}, not {expected}"),
124 Self::Overflow => f.write_str("cell value is outside the integer range"),
125 Self::Full { capacity } => write!(f, "cell table is full: capacity={capacity}"),
126 Self::Malformed(text) => write!(f, "not a cell id: {text:?}"),
127 Self::Io(error) => write!(f, "Orbit cell io error: {error}"),
128 }
129 }
130}
131
132impl std::error::Error for Error {
133 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
134 match self {
135 Self::Io(error) => Some(error),
136 _ => None,
137 }
138 }
139}
140
141impl From<std::io::Error> for Error {
142 fn from(value: std::io::Error) -> Self {
143 Self::Io(value)
144 }
145}
146
147#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
152pub struct CellId {
153 index: u32,
154 generation: u32,
155}
156
157impl CellId {
158 pub const fn index(self) -> u32 {
159 self.index
160 }
161
162 pub const fn generation(self) -> u32 {
163 self.generation
164 }
165
166 pub const fn to_bits(self) -> u64 {
168 ((self.index as u64) << 32) | self.generation as u64
169 }
170
171 pub const fn from_bits(bits: u64) -> Self {
172 Self {
173 index: (bits >> 32) as u32,
174 generation: bits as u32,
175 }
176 }
177}
178
179impl fmt::Display for CellId {
180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181 write!(f, "cell:{}:{}", self.index, self.generation)
182 }
183}
184
185impl FromStr for CellId {
186 type Err = Error;
187
188 fn from_str(text: &str) -> Result<Self> {
189 let malformed = || Error::Malformed(text.to_owned());
190 let rest = text.strip_prefix("cell:").ok_or_else(malformed)?;
191 let (index, generation) = rest.split_once(':').ok_or_else(malformed)?;
192 Ok(Self {
193 index: index.parse().map_err(|_| malformed())?,
194 generation: generation.parse().map_err(|_| malformed())?,
195 })
196 }
197}
198
199pub trait CellType: Copy + Send + Sync + 'static {
202 const TAG: u8;
203 const NAME: &'static str;
204
205 fn to_bits(self) -> u64;
206 fn from_bits(bits: u64) -> Self;
207}
208
209impl CellType for i64 {
210 const TAG: u8 = 1;
211 const NAME: &'static str = "int";
212
213 fn to_bits(self) -> u64 {
214 self as u64
215 }
216
217 fn from_bits(bits: u64) -> Self {
218 bits as i64
219 }
220}
221
222impl CellType for u64 {
223 const TAG: u8 = 2;
224 const NAME: &'static str = "unsigned";
225
226 fn to_bits(self) -> u64 {
227 self
228 }
229
230 fn from_bits(bits: u64) -> Self {
231 bits
232 }
233}
234
235impl CellType for f64 {
236 const TAG: u8 = 3;
237 const NAME: &'static str = "float";
238
239 fn to_bits(self) -> u64 {
240 self.to_bits()
241 }
242
243 fn from_bits(bits: u64) -> Self {
244 f64::from_bits(bits)
245 }
246}
247
248impl CellType for bool {
249 const TAG: u8 = 4;
250 const NAME: &'static str = "bool";
251
252 fn to_bits(self) -> u64 {
253 u64::from(self)
254 }
255
256 fn from_bits(bits: u64) -> Self {
257 bits != 0
258 }
259}
260
261fn type_name(tag: u8) -> &'static str {
262 match tag {
263 1 => i64::NAME,
264 2 => u64::NAME,
265 3 => f64::NAME,
266 4 => bool::NAME,
267 _ => "unknown",
268 }
269}
270
271#[derive(Clone)]
273pub struct Cells {
274 backend: CellBackend,
275 text: text::TextBackend,
276}
277
278#[derive(Clone)]
279enum CellBackend {
280 InMemory(Arc<MemoryCellTable>),
281 #[cfg(unix)]
282 Shm(Arc<ShmCellTable>),
283}
284
285impl Cells {
286 pub fn new(fleet: Arc<Fleet>) -> Result<Self> {
287 let backend = if fleet.is_shm() {
288 #[cfg(unix)]
289 {
290 CellBackend::Shm(Arc::new(ShmCellTable::open_or_create(&ring_segment_name(
291 fleet.name(),
292 CELL_STATE_KIND,
293 ))?))
294 }
295 #[cfg(not(unix))]
296 unreachable!("non-Unix fleets cannot use POSIX SHM")
297 } else {
298 CellBackend::InMemory(memory_table(&fleet))
299 };
300 let text = text::TextBackend::new(&fleet)?;
301 Ok(Self { backend, text })
302 }
303
304 pub fn allocate_text(&self, initial: &str) -> Result<Text> {
306 self.text.allocate(initial)
307 }
308
309 pub fn open_text(&self, id: TextId) -> Result<Text> {
311 self.text.open(id)
312 }
313
314 pub fn release_text(&self, id: TextId) -> Result<()> {
315 self.text.release(id)
316 }
317
318 pub fn is_text_live(&self, id: TextId) -> bool {
319 self.text.is_live(id)
320 }
321
322 pub fn allocate<T: CellType>(&self, initial: T) -> Result<Orbital<T>> {
325 let id = self.with_structure(|slots, hint| {
326 let capacity = slots.len();
327 let start = hint.load(Ordering::Relaxed) as usize;
328 for offset in 0..capacity {
329 let index = (start + offset) & (capacity - 1);
330 let slot = &slots[index];
331 if slot.state.load(Ordering::Acquire) == SLOT_EMPTY {
332 let generation = slot.install(T::TAG, initial.to_bits());
333 hint.store(((index + 1) & (capacity - 1)) as u32, Ordering::Relaxed);
334 return Ok(CellId {
335 index: index as u32,
336 generation,
337 });
338 }
339 }
340 Err(Error::Full { capacity })
341 })?;
342 Ok(Orbital {
343 cells: self.clone(),
344 id,
345 _type: PhantomData,
346 })
347 }
348
349 pub fn open<T: CellType>(&self, id: CellId) -> Result<Orbital<T>> {
351 let slot = self.slot(id)?;
352 let found = slot.tag.load(Ordering::Relaxed);
353 if found != T::TAG {
354 return Err(Error::TypeMismatch {
355 id,
356 expected: T::NAME,
357 found: type_name(found),
358 });
359 }
360 Ok(Orbital {
361 cells: self.clone(),
362 id,
363 _type: PhantomData,
364 })
365 }
366
367 pub fn release(&self, id: CellId) -> Result<()> {
369 self.with_structure(|slots, _| {
370 let slot = slots
371 .get(id.index as usize)
372 .filter(|slot| slot.is(id))
373 .ok_or(Error::Stale(id))?;
374 slot.state.store(SLOT_EMPTY, Ordering::Release);
375 slot.changed();
377 Ok(())
378 })
379 }
380
381 pub fn is_live(&self, id: CellId) -> bool {
383 self.slot(id).is_ok()
384 }
385
386 pub fn reset_all(&self) -> Result<()> {
388 self.with_structure(|slots, hint| {
389 for slot in slots {
390 slot.state.store(SLOT_EMPTY, Ordering::Release);
391 slot.changed();
392 }
393 hint.store(0, Ordering::Relaxed);
394 Ok(())
395 })?;
396 self.text.reset_all()
397 }
398
399 #[cfg(unix)]
402 pub fn unlink(&self) -> Result<()> {
403 match &self.backend {
404 CellBackend::InMemory(_) => self.reset_all()?,
405 CellBackend::Shm(table) => table.region.unlink().map_err(Error::Io)?,
406 }
407 self.text.unlink()
408 }
409
410 fn slots(&self) -> &[CellSlot] {
411 match &self.backend {
412 CellBackend::InMemory(table) => &table.slots,
413 #[cfg(unix)]
414 CellBackend::Shm(table) => table.slots(),
415 }
416 }
417
418 fn slot(&self, id: CellId) -> Result<&CellSlot> {
419 self.slots()
420 .get(id.index as usize)
421 .filter(|slot| slot.is(id))
422 .ok_or(Error::Stale(id))
423 }
424
425 fn with_structure<T>(
426 &self,
427 operation: impl FnOnce(&[CellSlot], &AtomicU32) -> Result<T>,
428 ) -> Result<T> {
429 match &self.backend {
430 CellBackend::InMemory(table) => {
431 let _local = lock_unpoisoned(&table.structural_lock);
432 operation(&table.slots, &table.hint)
433 }
434 #[cfg(unix)]
435 CellBackend::Shm(table) => table.with_structure(operation),
436 }
437 }
438}
439
440#[derive(Clone)]
442pub struct Orbital<T: CellType> {
443 cells: Cells,
444 id: CellId,
445 _type: PhantomData<T>,
446}
447
448impl<T: CellType> Orbital<T> {
449 pub fn id(&self) -> CellId {
450 self.id
451 }
452
453 pub fn load(&self) -> Result<T> {
454 Ok(T::from_bits(self.slot()?.value.load(Ordering::Acquire)))
455 }
456
457 pub fn store(&self, value: T) -> Result<()> {
458 let slot = self.slot()?;
459 slot.value.store(value.to_bits(), Ordering::Release);
460 slot.changed();
461 Ok(())
462 }
463
464 pub fn swap(&self, value: T) -> Result<T> {
466 let slot = self.slot()?;
467 let previous = slot.value.swap(value.to_bits(), Ordering::AcqRel);
468 slot.changed();
469 Ok(T::from_bits(previous))
470 }
471
472 pub fn version(&self) -> Result<u32> {
475 Ok(self.slot()?.changes.load(Ordering::Acquire))
476 }
477
478 pub fn wait_changed(&self, since: u32) -> Result<u32> {
483 loop {
484 let slot = self.slot()?;
485 let now = slot.changes.load(Ordering::SeqCst);
486 if now != since {
487 return Ok(now);
488 }
489 slot.waiters.fetch_add(1, Ordering::SeqCst);
490 let outcome = if slot.changes.load(Ordering::SeqCst) == since {
491 wait_on(&slot.changes, since)
492 } else {
493 Ok(())
494 };
495 slot.waiters.fetch_sub(1, Ordering::SeqCst);
496 outcome?;
497 }
498 }
499
500 pub fn compare_exchange(&self, current: T, new: T) -> Result<std::result::Result<T, T>> {
503 let slot = self.slot()?;
504 let exchanged = slot.value.compare_exchange(
505 current.to_bits(),
506 new.to_bits(),
507 Ordering::AcqRel,
508 Ordering::Acquire,
509 );
510 if exchanged.is_ok() {
511 slot.changed();
512 }
513 Ok(exchanged.map(T::from_bits).map_err(T::from_bits))
514 }
515
516 pub fn release(self) -> Result<()> {
518 self.cells.release(self.id)
519 }
520
521 fn slot(&self) -> Result<&CellSlot> {
522 self.cells.slot(self.id)
523 }
524}
525
526impl Orbital<i64> {
527 pub fn fetch_add(&self, by: i64) -> Result<i64> {
529 let slot = self.slot()?;
530 slot.value
531 .try_update(Ordering::AcqRel, Ordering::Acquire, |bits| {
532 (bits as i64).checked_add(by).map(|value| value as u64)
533 })
534 .map(|previous| {
535 slot.changed();
536 (previous as i64) + by
537 })
538 .map_err(|_| Error::Overflow)
539 }
540
541 pub fn fetch_sub(&self, by: i64) -> Result<i64> {
542 self.fetch_add(by.checked_neg().ok_or(Error::Overflow)?)
543 }
544}
545
546impl Orbital<u64> {
547 pub fn fetch_add(&self, by: u64) -> Result<u64> {
548 let slot = self.slot()?;
549 slot.value
550 .try_update(Ordering::AcqRel, Ordering::Acquire, |bits| {
551 bits.checked_add(by)
552 })
553 .map(|previous| {
554 slot.changed();
555 previous + by
556 })
557 .map_err(|_| Error::Overflow)
558 }
559
560 pub fn fetch_sub(&self, by: u64) -> Result<u64> {
561 let slot = self.slot()?;
562 slot.value
563 .try_update(Ordering::AcqRel, Ordering::Acquire, |bits| {
564 bits.checked_sub(by)
565 })
566 .map(|previous| {
567 slot.changed();
568 previous - by
569 })
570 .map_err(|_| Error::Overflow)
571 }
572}
573
574impl Orbital<f64> {
575 pub fn fetch_add(&self, by: f64) -> Result<f64> {
578 let slot = self.slot()?;
579 let previous = slot
580 .value
581 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |bits| {
582 Some((f64::from_bits(bits) + by).to_bits())
583 })
584 .unwrap_or_else(|bits| bits);
585 slot.changed();
586 Ok(f64::from_bits(previous) + by)
587 }
588}
589
590impl Orbital<bool> {
591 pub fn set(&self) -> Result<bool> {
593 self.swap(true)
594 }
595
596 pub fn clear(&self) -> Result<bool> {
598 self.swap(false)
599 }
600}
601
602#[repr(C, align(64))]
603struct CellStateHeader {
604 #[cfg(unix)]
605 magic: u32,
606 #[cfg(not(unix))]
607 _magic: u32,
608 #[cfg(unix)]
609 version: u16,
610 #[cfg(not(unix))]
611 _version: u16,
612 header_size: u16,
613 capacity: u32,
614 slot_size: u32,
615 hint: AtomicU32,
617 _reserved: [u8; 44],
618}
619
620impl CellStateHeader {
621 fn new() -> Self {
622 Self {
623 #[cfg(unix)]
624 magic: STATE_MAGIC,
625 #[cfg(not(unix))]
626 _magic: 0,
627 #[cfg(unix)]
628 version: STATE_VERSION,
629 #[cfg(not(unix))]
630 _version: 0,
631 header_size: size_of::<Self>() as u16,
632 capacity: CELL_CAPACITY as u32,
633 slot_size: size_of::<CellSlot>() as u32,
634 hint: AtomicU32::new(0),
635 _reserved: [0; 44],
636 }
637 }
638}
639
640#[repr(C, align(64))]
644struct CellSlot {
645 state: AtomicU8,
646 tag: AtomicU8,
647 _reserved: [u8; 2],
648 generation: AtomicU32,
649 value: AtomicU64,
650 changes: AtomicU32,
653 waiters: AtomicU32,
656 _padding: [u8; 40],
657}
658
659impl CellSlot {
660 fn empty() -> Self {
661 Self {
662 state: AtomicU8::new(SLOT_EMPTY),
663 tag: AtomicU8::new(0),
664 _reserved: [0; 2],
665 generation: AtomicU32::new(0),
666 value: AtomicU64::new(0),
667 changes: AtomicU32::new(0),
668 waiters: AtomicU32::new(0),
669 _padding: [0; 40],
670 }
671 }
672
673 fn changed(&self) {
678 self.changes.fetch_add(1, Ordering::SeqCst);
679 if self.waiters.load(Ordering::SeqCst) > 0 {
680 wake_on(&self.changes);
681 }
682 }
683
684 fn is(&self, id: CellId) -> bool {
685 self.state.load(Ordering::Acquire) == SLOT_OCCUPIED
687 && self.generation.load(Ordering::Relaxed) == id.generation
688 }
689
690 fn install(&self, tag: u8, value: u64) -> u32 {
693 let generation = self
694 .generation
695 .load(Ordering::Relaxed)
696 .wrapping_add(1)
697 .max(1);
698 self.tag.store(tag, Ordering::Relaxed);
699 self.value.store(value, Ordering::Relaxed);
700 self.changes.store(0, Ordering::Relaxed);
701 self.waiters.store(0, Ordering::Relaxed);
702 self.generation.store(generation, Ordering::Relaxed);
703 self.state.store(SLOT_OCCUPIED, Ordering::Release);
704 generation
705 }
706}
707
708pub(crate) fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
709 mutex.lock().unwrap_or_else(|error| error.into_inner())
710}
711
712struct MemoryCellTable {
713 slots: Vec<CellSlot>,
714 hint: AtomicU32,
715 structural_lock: Mutex<()>,
716}
717
718impl MemoryCellTable {
719 fn new() -> Self {
720 Self {
721 slots: (0..CELL_CAPACITY).map(|_| CellSlot::empty()).collect(),
722 hint: AtomicU32::new(0),
723 structural_lock: Mutex::new(()),
724 }
725 }
726}
727
728type MemoryRegistry = HashMap<usize, Weak<MemoryCellTable>>;
729
730static MEMORY_TABLES: LazyLock<Mutex<MemoryRegistry>> =
731 LazyLock::new(|| Mutex::new(HashMap::new()));
732
733fn memory_table(fleet: &Arc<Fleet>) -> Arc<MemoryCellTable> {
734 let fleet_identity = Arc::as_ptr(fleet) as usize;
735 let mut tables = lock_unpoisoned(&MEMORY_TABLES);
736 tables.retain(|_, table| table.strong_count() > 0);
737 if let Some(table) = tables.get(&fleet_identity).and_then(Weak::upgrade) {
738 return table;
739 }
740 let table = Arc::new(MemoryCellTable::new());
741 tables.insert(fleet_identity, Arc::downgrade(&table));
742 table
743}
744
745#[cfg(unix)]
746struct ShmCellTable {
747 region: ShmRegion,
748 structural_lock: Mutex<()>,
749}
750
751#[cfg(unix)]
752impl ShmCellTable {
753 fn open_or_create(name: &str) -> Result<Self> {
754 use std::{io, ptr};
755
756 let (region, _initialization_lock) =
757 ShmRegion::open_or_create_locked(name, shm_segment_size())?;
758 if region.created() {
759 unsafe {
760 ptr::write(
761 region.as_ptr().cast::<CellStateHeader>(),
762 CellStateHeader::new(),
763 );
764 let slots = region.as_ptr().add(size_of::<CellStateHeader>());
765 ptr::write_bytes(slots, 0, CELL_CAPACITY * size_of::<CellSlot>());
766 }
767 } else {
768 let header = unsafe { &*region.as_ptr().cast::<CellStateHeader>() };
769 if header.magic != STATE_MAGIC {
770 return Err(Error::Io(io::Error::new(
771 io::ErrorKind::InvalidData,
772 format!(
773 "SHM segment {name} has wrong magic 0x{:08X} (expected 0x{STATE_MAGIC:08X})",
774 header.magic
775 ),
776 )));
777 }
778 if header.version != STATE_VERSION
779 || usize::from(header.header_size) != size_of::<CellStateHeader>()
780 || header.capacity as usize != CELL_CAPACITY
781 || header.slot_size as usize != size_of::<CellSlot>()
782 {
783 return Err(Error::Io(io::Error::new(
784 io::ErrorKind::InvalidData,
785 format!("SHM segment {name} has an incompatible cell-state layout"),
786 )));
787 }
788 }
789 Ok(Self {
790 region,
791 structural_lock: Mutex::new(()),
792 })
793 }
794
795 fn header(&self) -> &CellStateHeader {
796 unsafe { &*self.region.as_ptr().cast::<CellStateHeader>() }
797 }
798
799 fn slots(&self) -> &[CellSlot] {
800 unsafe {
801 std::slice::from_raw_parts(
802 self.region
803 .as_ptr()
804 .add(size_of::<CellStateHeader>())
805 .cast::<CellSlot>(),
806 CELL_CAPACITY,
807 )
808 }
809 }
810
811 fn with_structure<T>(
812 &self,
813 operation: impl FnOnce(&[CellSlot], &AtomicU32) -> Result<T>,
814 ) -> Result<T> {
815 let _local = lock_unpoisoned(&self.structural_lock);
816 let _process = self.region.lock_exclusive()?;
817 operation(self.slots(), &self.header().hint)
818 }
819}
820
821#[cfg(unix)]
822fn shm_segment_size() -> usize {
823 size_of::<CellStateHeader>() + CELL_CAPACITY * size_of::<CellSlot>()
824}
825
826const _: () = assert!(CELL_CAPACITY.is_power_of_two());
827const _: () = assert!(CELL_CAPACITY <= u32::MAX as usize);
828const _: () = assert!(size_of::<CellStateHeader>() == 64);
829const _: () = assert!(size_of::<CellSlot>() == 64);
830
831#[cfg(test)]
832mod wait_tests {
833 use std::sync::Arc;
834 use std::time::Duration;
835
836 use orbit_core::Fleet;
837
838 use super::{Cells, Error};
839
840 fn cells() -> Cells {
841 Cells::new(Arc::new(Fleet::join("cell-wait-test", 1).unwrap())).unwrap()
842 }
843
844 #[test]
848 fn a_write_wakes_a_waiter_once() {
849 let cells = cells();
850 let cell = cells.allocate(0_i64).unwrap();
851 let since = cell.version().unwrap();
852 let watcher = cell.clone();
853 let waiter = std::thread::spawn(move || watcher.wait_changed(since));
854
855 std::thread::sleep(Duration::from_millis(30));
856 for _ in 0..10 {
857 cell.fetch_add(1).unwrap();
858 }
859 let now = waiter.join().unwrap().unwrap();
860 assert!(now > since);
861 assert_eq!(cell.load().unwrap(), 10);
862 assert_eq!(cell.version().unwrap(), cell.version().unwrap());
864 }
865
866 #[test]
869 fn release_wakes_a_waiter_stale() {
870 let cells = cells();
871 let cell = cells.allocate(7_i64).unwrap();
872 let since = cell.version().unwrap();
873 let watcher = cell.clone();
874 let waiter = std::thread::spawn(move || watcher.wait_changed(since));
875
876 std::thread::sleep(Duration::from_millis(30));
877 cell.release().unwrap();
878 assert!(matches!(waiter.join().unwrap(), Err(Error::Stale(_))));
879 }
880}