Skip to main content

orbit_cell/
text.rs

1//! Fixed-width text cells: the string arena beside the scalar table.
2//!
3//! A text cell holds up to [`CELL_TEXT_MAX`] bytes of UTF-8 in shared memory.
4//! Sixty-four bits can be swapped in one instruction; a string cannot, so a
5//! cell is a seqlock: writers take the cell's own spin bit and bump a version
6//! around their write, readers copy and accept the copy only if the version
7//! held still. Readers never block writers and never see a torn string.
8
9use std::collections::HashMap;
10use std::fmt;
11use std::str::FromStr;
12use std::sync::atomic::{AtomicU8, AtomicU16, AtomicU32, Ordering};
13use std::sync::{Arc, LazyLock, Mutex, Weak};
14
15use orbit_core::Fleet;
16#[cfg(unix)]
17use orbit_core::shm::{ShmRegion, ring_segment_name};
18
19use crate::{Error, Result, SLOT_EMPTY, SLOT_OCCUPIED, lock_unpoisoned};
20
21/// Reserved Orbit SHM kind for the text cell table.
22pub const CELL_TEXT_KIND: u8 = 227;
23/// Number of text cells one fleet generation can hold at once.
24///
25/// Compile-time geometry: `ORBIT_CELL_TEXT_CAPACITY`. Must be a power of two.
26pub const CELL_TEXT_CAPACITY: usize = orbit_core::compile::usize_from_env(
27    option_env!("ORBIT_CELL_TEXT_CAPACITY"),
28    1_024,
29);
30/// Bytes one text cell can hold.
31///
32/// Compile-time geometry: `ORBIT_CELL_TEXT_MAX`. It sizes every slot, so it is
33/// part of the same wire contract as the capacity.
34pub const CELL_TEXT_MAX: usize = orbit_core::compile::usize_from_env(
35    option_env!("ORBIT_CELL_TEXT_MAX"),
36    240,
37);
38
39#[cfg(unix)]
40const TEXT_MAGIC: u32 = 0x43_54_58_54; // "CTXT"
41#[cfg(unix)]
42const TEXT_VERSION: u16 = 2;
43
44/// The address of one text cell; prints as `text:<slot>:<generation>`.
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
46pub struct TextId {
47    index: u32,
48    generation: u32,
49}
50
51impl TextId {
52    pub const fn index(self) -> u32 {
53        self.index
54    }
55
56    pub const fn generation(self) -> u32 {
57        self.generation
58    }
59
60    pub const fn to_bits(self) -> u64 {
61        ((self.index as u64) << 32) | self.generation as u64
62    }
63
64    pub const fn from_bits(bits: u64) -> Self {
65        Self { index: (bits >> 32) as u32, generation: bits as u32 }
66    }
67}
68
69impl fmt::Display for TextId {
70    fn fmt(
71        &self,
72        f: &mut fmt::Formatter<'_>,
73    ) -> fmt::Result {
74        write!(f, "text:{}:{}", self.index, self.generation)
75    }
76}
77
78impl FromStr for TextId {
79    type Err = Error;
80
81    fn from_str(text: &str) -> Result<Self> {
82        let malformed = || Error::Malformed(text.to_owned());
83        let rest = text.strip_prefix("text:").ok_or_else(malformed)?;
84        let (index, generation) = rest.split_once(':').ok_or_else(malformed)?;
85        Ok(Self {
86            index: index.parse().map_err(|_| malformed())?,
87            generation: generation.parse().map_err(|_| malformed())?,
88        })
89    }
90}
91
92/// One text cell. Cheap to clone; every clone is the same place.
93#[derive(Clone)]
94pub struct Text {
95    table: TextBackend,
96    id: TextId,
97}
98
99impl Text {
100    pub fn id(&self) -> TextId {
101        self.id
102    }
103
104    /// A copy of the current text.
105    pub fn load(&self) -> Result<String> {
106        let slot = self.slot()?;
107        let mut bytes = vec![0_u8; CELL_TEXT_MAX];
108        loop {
109            let before = slot.version.load(Ordering::Acquire);
110            if before & 1 == 1 {
111                std::hint::spin_loop();
112                continue;
113            }
114            let len = usize::from(slot.len.load(Ordering::Relaxed))
115                .min(CELL_TEXT_MAX);
116            for (target, source) in
117                bytes[..len].iter_mut().zip(&slot.bytes[..len])
118            {
119                *target = source.load(Ordering::Relaxed);
120            }
121            std::sync::atomic::fence(Ordering::Acquire);
122            if slot.version.load(Ordering::Relaxed) == before {
123                bytes.truncate(len);
124                return String::from_utf8(bytes)
125                    .map_err(|_| Error::Corrupt(self.id.to_string()));
126            }
127        }
128    }
129
130    pub fn len(&self) -> Result<usize> {
131        Ok(usize::from(self.slot()?.len.load(Ordering::Acquire)))
132    }
133
134    pub fn is_empty(&self) -> Result<bool> {
135        Ok(self.len()? == 0)
136    }
137
138    /// Replace the text.
139    pub fn store(
140        &self,
141        text: &str,
142    ) -> Result<()> {
143        if text.len() > CELL_TEXT_MAX {
144            return Err(Error::TooLong { len: text.len(), max: CELL_TEXT_MAX });
145        }
146        let slot = self.slot()?;
147        let _write = slot.writer();
148        slot.write(0, text.as_bytes());
149        Ok(())
150    }
151
152    /// Append to the text, atomically with respect to every other writer;
153    /// refused whole, never cut, when the result would not fit.
154    pub fn append(
155        &self,
156        text: &str,
157    ) -> Result<usize> {
158        let slot = self.slot()?;
159        let _write = slot.writer();
160        let current = usize::from(slot.len.load(Ordering::Relaxed));
161        let total = current + text.len();
162        if total > CELL_TEXT_MAX {
163            return Err(Error::TooLong { len: total, max: CELL_TEXT_MAX });
164        }
165        slot.write(current, text.as_bytes());
166        Ok(total)
167    }
168
169    /// Give the cell back; this and every other handle to it go stale.
170    pub fn release(self) -> Result<()> {
171        self.table.release(self.id)
172    }
173
174    /// The seqlock sequence at rest, the thing [`Self::wait_changed`] waits
175    /// on. Take it before [`Self::load`] and the wait misses nothing.
176    pub fn version(&self) -> Result<u32> {
177        let slot = self.slot()?;
178        loop {
179            let version = slot.version.load(Ordering::Acquire);
180            if version & 1 == 0 {
181                return Ok(version);
182            }
183            std::hint::spin_loop();
184        }
185    }
186
187    /// Park until the text has been written since `since`, then return the
188    /// sequence now. A released cell wakes every waiter with
189    /// [`Error::StaleText`]. Blocking; an async runtime wraps it.
190    pub fn wait_changed(
191        &self,
192        since: u32,
193    ) -> Result<u32> {
194        loop {
195            let slot = self.slot()?;
196            let now = slot.version.load(Ordering::SeqCst);
197            if now != since {
198                if now & 1 == 1 {
199                    std::hint::spin_loop();
200                    continue;
201                }
202                return Ok(now);
203            }
204            slot.waiters.fetch_add(1, Ordering::SeqCst);
205            let outcome = if slot.version.load(Ordering::SeqCst) == since {
206                crate::wait_on(&slot.version, since)
207            } else {
208                Ok(())
209            };
210            slot.waiters.fetch_sub(1, Ordering::SeqCst);
211            outcome?;
212        }
213    }
214
215    /// [`Self::wait_changed`], but a second word can end it. `Ok(None)`
216    /// means the interrupt arrived, not that time ran out — nothing here
217    /// measures time. The canceller sets `interrupt`, then calls
218    /// [`Self::wake_waiters`].
219    pub fn wait_changed_until(
220        &self,
221        since: u32,
222        interrupt: &core::sync::atomic::AtomicBool,
223    ) -> Result<Option<u32>> {
224        loop {
225            if interrupt.load(Ordering::SeqCst) {
226                return Ok(None);
227            }
228
229            let slot = self.slot()?;
230            let now = slot.version.load(Ordering::SeqCst);
231            if now != since {
232                if now & 1 == 1 {
233                    std::hint::spin_loop();
234                    continue;
235                }
236                return Ok(Some(now));
237            }
238
239            slot.waiters.fetch_add(1, Ordering::SeqCst);
240            let outcome = if slot.version.load(Ordering::SeqCst) == since
241                && !interrupt.load(Ordering::SeqCst)
242            {
243                crate::wait_on(&slot.version, since)
244            } else {
245                Ok(())
246            };
247            slot.waiters.fetch_sub(1, Ordering::SeqCst);
248            outcome?;
249        }
250    }
251
252    /// Wake everyone parked on this text cell without appending to it.
253    /// Only useful after the word the waiter also watches has been set.
254    pub fn wake_waiters(&self) -> Result<()> {
255        crate::wake_on(&self.slot()?.version);
256        Ok(())
257    }
258
259    fn slot(&self) -> Result<&TextSlot> {
260        self.table.slot(self.id)
261    }
262}
263
264#[derive(Clone)]
265pub(crate) enum TextBackend {
266    InMemory(Arc<MemoryTextTable>),
267    #[cfg(unix)]
268    Shm(Arc<ShmTextTable>),
269}
270
271impl TextBackend {
272    pub(crate) fn new(fleet: &Arc<Fleet>) -> Result<Self> {
273        Ok(if fleet.is_shm() {
274            #[cfg(unix)]
275            {
276                Self::Shm(Arc::new(ShmTextTable::open_or_create(
277                    &ring_segment_name(fleet.name(), CELL_TEXT_KIND),
278                )?))
279            }
280            #[cfg(not(unix))]
281            unreachable!("non-Unix fleets cannot use POSIX SHM")
282        } else {
283            Self::InMemory(memory_table(fleet))
284        })
285    }
286
287    pub(crate) fn allocate(
288        &self,
289        text: &str,
290    ) -> Result<Text> {
291        if text.len() > CELL_TEXT_MAX {
292            return Err(Error::TooLong { len: text.len(), max: CELL_TEXT_MAX });
293        }
294        let id = self.with_structure(|slots, hint| {
295            let capacity = slots.len();
296            let start = hint.load(Ordering::Relaxed) as usize;
297            for offset in 0..capacity {
298                let index = (start + offset) & (capacity - 1);
299                let slot = &slots[index];
300                if slot.state.load(Ordering::Acquire) == SLOT_EMPTY {
301                    let generation = slot.install(text.as_bytes());
302                    hint.store(
303                        ((index + 1) & (capacity - 1)) as u32,
304                        Ordering::Relaxed,
305                    );
306                    return Ok(TextId { index: index as u32, generation });
307                }
308            }
309            Err(Error::Full { capacity })
310        })?;
311        Ok(Text { table: self.clone(), id })
312    }
313
314    pub(crate) fn open(
315        &self,
316        id: TextId,
317    ) -> Result<Text> {
318        self.slot(id)?;
319        Ok(Text { table: self.clone(), id })
320    }
321
322    pub(crate) fn release(
323        &self,
324        id: TextId,
325    ) -> Result<()> {
326        self.with_structure(|slots, _| {
327            let slot = slots
328                .get(id.index as usize)
329                .filter(|slot| slot.is(id))
330                .ok_or(Error::StaleText(id))?;
331            slot.state.store(SLOT_EMPTY, Ordering::Release);
332            // An even step keeps the seqlock at rest; waiters find it gone.
333            slot.version.fetch_add(2, Ordering::SeqCst);
334            slot.wake_waiters();
335            Ok(())
336        })
337    }
338
339    pub(crate) fn is_live(
340        &self,
341        id: TextId,
342    ) -> bool {
343        self.slot(id).is_ok()
344    }
345
346    pub(crate) fn reset_all(&self) -> Result<()> {
347        self.with_structure(|slots, hint| {
348            for slot in slots {
349                slot.state.store(SLOT_EMPTY, Ordering::Release);
350                slot.version.fetch_add(2, Ordering::SeqCst);
351                slot.wake_waiters();
352            }
353            hint.store(0, Ordering::Relaxed);
354            Ok(())
355        })
356    }
357
358    #[cfg(unix)]
359    pub(crate) fn unlink(&self) -> Result<()> {
360        match self {
361            Self::InMemory(_) => self.reset_all(),
362            Self::Shm(table) => table.region.unlink().map_err(Error::Io),
363        }
364    }
365
366    fn slots(&self) -> &[TextSlot] {
367        match self {
368            Self::InMemory(table) => &table.slots,
369            #[cfg(unix)]
370            Self::Shm(table) => table.slots(),
371        }
372    }
373
374    fn slot(
375        &self,
376        id: TextId,
377    ) -> Result<&TextSlot> {
378        self.slots()
379            .get(id.index as usize)
380            .filter(|slot| slot.is(id))
381            .ok_or(Error::StaleText(id))
382    }
383
384    fn with_structure<T>(
385        &self,
386        operation: impl FnOnce(&[TextSlot], &AtomicU32) -> Result<T>,
387    ) -> Result<T> {
388        match self {
389            Self::InMemory(table) => {
390                let _local = lock_unpoisoned(&table.structural_lock);
391                operation(&table.slots, &table.hint)
392            }
393            #[cfg(unix)]
394            Self::Shm(table) => table.with_structure(operation),
395        }
396    }
397}
398
399#[repr(C, align(64))]
400struct TextStateHeader {
401    #[cfg(unix)]
402    magic: u32,
403    #[cfg(not(unix))]
404    _magic: u32,
405    #[cfg(unix)]
406    version: u16,
407    #[cfg(not(unix))]
408    _version: u16,
409    header_size: u16,
410    capacity: u32,
411    slot_size: u32,
412    hint: AtomicU32,
413    _reserved: [u8; 44],
414}
415
416impl TextStateHeader {
417    fn new() -> Self {
418        Self {
419            #[cfg(unix)]
420            magic: TEXT_MAGIC,
421            #[cfg(not(unix))]
422            _magic: 0,
423            #[cfg(unix)]
424            version: TEXT_VERSION,
425            #[cfg(not(unix))]
426            _version: 0,
427            header_size: size_of::<Self>() as u16,
428            capacity: CELL_TEXT_CAPACITY as u32,
429            slot_size: size_of::<TextSlot>() as u32,
430            hint: AtomicU32::new(0),
431            _reserved: [0; 44],
432        }
433    }
434}
435
436/// One text cell in shared memory: a seqlock version, a writer bit, a
437/// length and the bytes, padded to the cache line.
438#[repr(C, align(64))]
439struct TextSlot {
440    state: AtomicU8,
441    writer: AtomicU8,
442    len: AtomicU16,
443    generation: AtomicU32,
444    /// The seqlock's sequence: odd while a write is in progress, and the
445    /// word a waiter parks on, since every write moves it.
446    version: AtomicU32,
447    /// Waiters parked on `version`; a writer wakes only when nonzero.
448    waiters: AtomicU32,
449    bytes: [AtomicU8; CELL_TEXT_MAX],
450}
451
452impl TextSlot {
453    fn empty() -> Self {
454        Self {
455            state: AtomicU8::new(SLOT_EMPTY),
456            writer: AtomicU8::new(0),
457            len: AtomicU16::new(0),
458            generation: AtomicU32::new(0),
459            version: AtomicU32::new(0),
460            waiters: AtomicU32::new(0),
461            bytes: std::array::from_fn(|_| AtomicU8::new(0)),
462        }
463    }
464
465    fn is(
466        &self,
467        id: TextId,
468    ) -> bool {
469        self.state.load(Ordering::Acquire) == SLOT_OCCUPIED
470            && self.generation.load(Ordering::Relaxed) == id.generation
471    }
472
473    /// Under the structural lock.
474    fn install(
475        &self,
476        text: &[u8],
477    ) -> u32 {
478        let generation =
479            self.generation.load(Ordering::Relaxed).wrapping_add(1).max(1);
480        for (index, byte) in self.bytes.iter().enumerate() {
481            byte.store(
482                text.get(index).copied().unwrap_or(0),
483                Ordering::Relaxed,
484            );
485        }
486        self.len.store(text.len() as u16, Ordering::Relaxed);
487        self.version.store(0, Ordering::Relaxed);
488        self.waiters.store(0, Ordering::Relaxed);
489        self.writer.store(0, Ordering::Relaxed);
490        self.generation.store(generation, Ordering::Relaxed);
491        self.state.store(SLOT_OCCUPIED, Ordering::Release);
492        generation
493    }
494
495    /// The cell's writer bit, held for the duration of one write.
496    fn writer(&self) -> WriterGuard<'_> {
497        while self
498            .writer
499            .compare_exchange_weak(0, 1, Ordering::Acquire, Ordering::Relaxed)
500            .is_err()
501        {
502            std::hint::spin_loop();
503        }
504        WriterGuard(self)
505    }
506
507    /// Write `bytes` at `offset` and publish the new length; the caller holds
508    /// the writer bit and has checked the fit.
509    fn write(
510        &self,
511        offset: usize,
512        bytes: &[u8],
513    ) {
514        debug_assert!(offset + bytes.len() <= CELL_TEXT_MAX);
515        self.version.fetch_add(1, Ordering::AcqRel);
516        for (target, source) in
517            self.bytes[offset..offset + bytes.len()].iter().zip(bytes)
518        {
519            target.store(*source, Ordering::Relaxed);
520        }
521        self.len.store((offset + bytes.len()) as u16, Ordering::Relaxed);
522        self.version.fetch_add(1, Ordering::SeqCst);
523        self.wake_waiters();
524    }
525
526    /// The sequence moved: wake whoever parked on it, if anyone did.
527    fn wake_waiters(&self) {
528        if self.waiters.load(Ordering::SeqCst) > 0 {
529            crate::wake_on(&self.version);
530        }
531    }
532}
533
534struct WriterGuard<'a>(&'a TextSlot);
535
536impl Drop for WriterGuard<'_> {
537    fn drop(&mut self) {
538        self.0.writer.store(0, Ordering::Release);
539    }
540}
541
542pub(crate) struct MemoryTextTable {
543    /// Held so the fleet's address, which keys the registry, cannot be
544    /// reused by another fleet while this table lives.
545    _fleet: Arc<Fleet>,
546    slots: Vec<TextSlot>,
547    hint: AtomicU32,
548    structural_lock: Mutex<()>,
549}
550
551impl MemoryTextTable {
552    fn new(fleet: Arc<Fleet>) -> Self {
553        Self {
554            _fleet: fleet,
555            slots: (0..CELL_TEXT_CAPACITY).map(|_| TextSlot::empty()).collect(),
556            hint: AtomicU32::new(0),
557            structural_lock: Mutex::new(()),
558        }
559    }
560}
561
562type MemoryRegistry = HashMap<usize, Weak<MemoryTextTable>>;
563
564static MEMORY_TABLES: LazyLock<Mutex<MemoryRegistry>> =
565    LazyLock::new(|| Mutex::new(HashMap::new()));
566
567fn memory_table(fleet: &Arc<Fleet>) -> Arc<MemoryTextTable> {
568    let fleet_identity = Arc::as_ptr(fleet) as usize;
569    let mut tables = lock_unpoisoned(&MEMORY_TABLES);
570    tables.retain(|_, table| table.strong_count() > 0);
571    if let Some(table) = tables.get(&fleet_identity).and_then(Weak::upgrade) {
572        return table;
573    }
574    let table = Arc::new(MemoryTextTable::new(Arc::clone(fleet)));
575    tables.insert(fleet_identity, Arc::downgrade(&table));
576    table
577}
578
579#[cfg(unix)]
580pub(crate) struct ShmTextTable {
581    region: ShmRegion,
582    structural_lock: Mutex<()>,
583}
584
585#[cfg(unix)]
586impl ShmTextTable {
587    fn open_or_create(name: &str) -> Result<Self> {
588        use std::{io, ptr};
589
590        let (region, _initialization_lock) =
591            ShmRegion::open_or_create_locked(name, shm_segment_size())?;
592        if region.created() {
593            unsafe {
594                ptr::write(
595                    region.as_ptr().cast::<TextStateHeader>(),
596                    TextStateHeader::new(),
597                );
598                let slots = region.as_ptr().add(size_of::<TextStateHeader>());
599                ptr::write_bytes(
600                    slots,
601                    0,
602                    CELL_TEXT_CAPACITY * size_of::<TextSlot>(),
603                );
604            }
605        } else {
606            let header = unsafe { &*region.as_ptr().cast::<TextStateHeader>() };
607            if header.magic != TEXT_MAGIC {
608                return Err(Error::Io(io::Error::new(
609                    io::ErrorKind::InvalidData,
610                    format!(
611                        "SHM segment {name} has wrong magic 0x{:08X} (expected 0x{TEXT_MAGIC:08X})",
612                        header.magic
613                    ),
614                )));
615            }
616            if header.version != TEXT_VERSION
617                || usize::from(header.header_size)
618                    != size_of::<TextStateHeader>()
619                || header.capacity as usize != CELL_TEXT_CAPACITY
620                || header.slot_size as usize != size_of::<TextSlot>()
621            {
622                return Err(Error::Io(io::Error::new(
623                    io::ErrorKind::InvalidData,
624                    format!(
625                        "SHM segment {name} has an incompatible text-cell layout"
626                    ),
627                )));
628            }
629        }
630        Ok(Self { region, structural_lock: Mutex::new(()) })
631    }
632
633    fn header(&self) -> &TextStateHeader {
634        unsafe { &*self.region.as_ptr().cast::<TextStateHeader>() }
635    }
636
637    fn slots(&self) -> &[TextSlot] {
638        unsafe {
639            std::slice::from_raw_parts(
640                self.region
641                    .as_ptr()
642                    .add(size_of::<TextStateHeader>())
643                    .cast::<TextSlot>(),
644                CELL_TEXT_CAPACITY,
645            )
646        }
647    }
648
649    fn with_structure<T>(
650        &self,
651        operation: impl FnOnce(&[TextSlot], &AtomicU32) -> Result<T>,
652    ) -> Result<T> {
653        let _local = lock_unpoisoned(&self.structural_lock);
654        let _process = self.region.lock_exclusive()?;
655        operation(self.slots(), &self.header().hint)
656    }
657}
658
659#[cfg(unix)]
660fn shm_segment_size() -> usize {
661    size_of::<TextStateHeader>() + CELL_TEXT_CAPACITY * size_of::<TextSlot>()
662}
663
664const _: () = assert!(CELL_TEXT_CAPACITY.is_power_of_two());
665const _: () = assert!(CELL_TEXT_MAX > 0 && CELL_TEXT_MAX <= u16::MAX as usize);
666const _: () = assert!(size_of::<TextStateHeader>() == 64);
667// 16 bytes of fixed fields, then the text, padded to the cache line.
668const _: () =
669    assert!(size_of::<TextSlot>() == (16 + CELL_TEXT_MAX).next_multiple_of(64));