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 publishes an inactive bank with one full-width revision. A bounded
6//! reader validates that revision after copying; contention is explicit.
7//! The last committed bank survives a writer exit before publication.
8
9use std::collections::HashMap;
10use std::fmt;
11use std::str::FromStr;
12use std::sync::atomic::{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::lifetime::{self, Lease, Owners};
20use crate::{
21    Error, Result, SLOT_EMPTY, SLOT_OCCUPIED, SLOT_RETIRED, lock_unpoisoned,
22};
23
24mod slot;
25use slot::TextSlot;
26#[cfg(unix)]
27mod owners;
28#[cfg(test)]
29pub(crate) mod tests;
30
31/// Reserved Orbit SHM kind for the text cell table.
32pub const CELL_TEXT_KIND: u8 = 186;
33/// Number of text cells one fleet generation can hold at once.
34///
35/// Compile-time geometry: `ORBIT_CELL_TEXT_CAPACITY`. Must be a power of two.
36pub const CELL_TEXT_CAPACITY: usize = orbit_core::compile::usize_from_env(
37    option_env!("ORBIT_CELL_TEXT_CAPACITY"),
38    1_024,
39);
40/// Bytes one text cell can hold.
41///
42/// Compile-time geometry: `ORBIT_CELL_TEXT_MAX`. It sizes every slot, so it is
43/// part of the same wire contract as the capacity.
44pub const CELL_TEXT_MAX: usize = orbit_core::compile::usize_from_env(
45    option_env!("ORBIT_CELL_TEXT_MAX"),
46    240,
47);
48
49#[cfg(unix)]
50const TEXT_MAGIC: u32 = 0x43_54_58_54; // "CTXT"
51#[cfg(unix)]
52const TEXT_VERSION: u16 = 5;
53
54/// The address of one text cell; prints as `text:<slot>:<generation>`.
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
56pub struct TextId {
57    index: u32,
58    generation: u32,
59}
60
61impl TextId {
62    pub const fn index(self) -> u32 {
63        self.index
64    }
65
66    pub const fn generation(self) -> u32 {
67        self.generation
68    }
69
70    pub const fn to_bits(self) -> u64 {
71        ((self.index as u64) << 32) | self.generation as u64
72    }
73
74    pub const fn from_bits(bits: u64) -> Self {
75        Self { index: (bits >> 32) as u32, generation: bits as u32 }
76    }
77}
78
79impl fmt::Display for TextId {
80    fn fmt(
81        &self,
82        f: &mut fmt::Formatter<'_>,
83    ) -> fmt::Result {
84        write!(f, "text:{}:{}", self.index, self.generation)
85    }
86}
87
88impl FromStr for TextId {
89    type Err = Error;
90
91    fn from_str(text: &str) -> Result<Self> {
92        let malformed = || Error::Malformed(text.to_owned());
93        let rest = text.strip_prefix("text:").ok_or_else(malformed)?;
94        let (index, generation) = rest.split_once(':').ok_or_else(malformed)?;
95        Ok(Self {
96            index: index.parse().map_err(|_| malformed())?,
97            generation: generation.parse().map_err(|_| malformed())?,
98        })
99    }
100}
101
102/// One text cell. Cheap to clone; every clone is the same place.
103#[derive(Clone)]
104pub struct Text {
105    table: TextBackend,
106    id: TextId,
107    _lease: Arc<Lease>,
108}
109
110impl Text {
111    pub fn id(&self) -> TextId {
112        self.id
113    }
114
115    /// A bounded copy of the current text. Returns [`Error::Contended`] if
116    /// another publication reuses its bank while the copy is in progress.
117    pub fn load(&self) -> Result<String> {
118        let mut bytes = vec![0_u8; CELL_TEXT_MAX];
119        let len = self.slot()?.copy(self.id, &mut bytes)?;
120        bytes.truncate(len);
121        String::from_utf8(bytes)
122            .map_err(|_| Error::Corrupt(self.id.to_string()))
123    }
124
125    /// Copy the current text into caller-owned storage without allocating on
126    /// success.
127    /// The returned string borrows only `buffer`, not the shared-memory bank.
128    /// A buffer of [`CELL_TEXT_MAX`] bytes fits every valid text value.
129    ///
130    /// Like [`Self::load`], this makes one bounded attempt and returns
131    /// [`Error::Contended`] if publication changes during the copy. A buffer
132    /// too small for the validated snapshot returns [`Error::BufferTooSmall`],
133    /// never a truncated string. Buffer contents are unspecified on error.
134    pub fn load_into<'a>(
135        &self,
136        buffer: &'a mut [u8],
137    ) -> Result<&'a str> {
138        let len = self.slot()?.copy(self.id, buffer)?;
139        if len > buffer.len() {
140            return Err(Error::BufferTooSmall {
141                required: len,
142                available: buffer.len(),
143            });
144        }
145        std::str::from_utf8(&buffer[..len])
146            .map_err(|_| Error::Corrupt(self.id.to_string()))
147    }
148
149    pub fn len(&self) -> Result<usize> {
150        self.slot()?.copy(self.id, &mut [])
151    }
152
153    pub fn is_empty(&self) -> Result<bool> {
154        Ok(self.len()? == 0)
155    }
156
157    /// Replace the text. A live writer causes [`Error::Contended`], not a spin.
158    pub fn store(
159        &self,
160        text: &str,
161    ) -> Result<()> {
162        if text.len() > CELL_TEXT_MAX {
163            return Err(Error::TooLong { len: text.len(), max: CELL_TEXT_MAX });
164        }
165        let slot = self.slot()?;
166        slot.writer(self.table.writer_token())?
167            .write(text.as_bytes(), false)?;
168        Ok(())
169    }
170
171    /// Append by publishing a complete new bank. A refused write changes
172    /// neither the committed value nor its revision.
173    pub fn append(
174        &self,
175        text: &str,
176    ) -> Result<usize> {
177        self.slot()?
178            .writer(self.table.writer_token())?
179            .write(text.as_bytes(), true)
180    }
181
182    /// Retire this generation immediately. The physical slot remains pinned
183    /// until every handle to the old generation has been dropped.
184    pub fn release(self) -> Result<()> {
185        self.table.release(self.id)
186    }
187
188    /// The full-width committed revision token. Compare for equality; do not
189    /// truncate it or interpret its bank bit as an in-progress marker.
190    pub fn version(&self) -> Result<u64> {
191        Ok(self.slot()?.committed.load(Ordering::Acquire))
192    }
193
194    /// Park until the text has been written since `since`, then return the
195    /// sequence now. A released cell wakes every waiter with
196    /// [`Error::StaleText`]. Blocking; an async runtime wraps it.
197    pub fn wait_changed(
198        &self,
199        since: u64,
200    ) -> Result<u64> {
201        loop {
202            let slot = self.slot()?;
203            let now = slot.committed.load(Ordering::SeqCst);
204            if now != since {
205                return Ok(now);
206            }
207            slot.wake.wait_if(|| {
208                slot.state.load(Ordering::SeqCst) == SLOT_OCCUPIED
209                    && slot.committed.load(Ordering::SeqCst) == since
210            })?;
211        }
212    }
213
214    /// [`Self::wait_changed`], but a second word can end it. `Ok(None)`
215    /// means the interrupt arrived, not that time ran out — nothing here
216    /// measures time. The canceller sets `interrupt`, then calls
217    /// [`Self::wake_waiters`]. The wake advances a separate parking generation;
218    /// it does not change the text or its committed revision.
219    pub fn wait_changed_until(
220        &self,
221        since: u64,
222        interrupt: &core::sync::atomic::AtomicBool,
223    ) -> Result<Option<u64>> {
224        loop {
225            if interrupt.load(Ordering::SeqCst) {
226                return Ok(None);
227            }
228
229            let slot = self.slot()?;
230            let now = slot.committed.load(Ordering::SeqCst);
231            if now != since {
232                return Ok(Some(now));
233            }
234
235            slot.wake.wait_if(|| {
236                slot.state.load(Ordering::SeqCst) == SLOT_OCCUPIED
237                    && slot.committed.load(Ordering::SeqCst) == since
238                    && !interrupt.load(Ordering::SeqCst)
239            })?;
240        }
241    }
242
243    /// Wake everyone parked on this text cell without appending to it.
244    /// Only useful after the word the waiter also watches has been set.
245    pub fn wake_waiters(&self) -> Result<()> {
246        self.slot()?.wake.notify();
247        Ok(())
248    }
249
250    fn slot(&self) -> Result<&TextSlot> {
251        self.table.slot(self.id)
252    }
253}
254
255#[derive(Clone)]
256pub(crate) enum TextBackend {
257    InMemory(Arc<MemoryTextTable>),
258    #[cfg(unix)]
259    Shm(Arc<ShmTextTable>),
260}
261
262impl TextBackend {
263    pub(crate) fn new(fleet: &Arc<Fleet>) -> Result<Self> {
264        Ok(if fleet.is_shm() {
265            #[cfg(unix)]
266            {
267                Self::Shm(shm_table(
268                    &ring_segment_name(fleet.name(), CELL_TEXT_KIND),
269                    lifetime::owner_capacity(fleet),
270                )?)
271            }
272            #[cfg(not(unix))]
273            unreachable!("non-Unix fleets cannot use POSIX SHM")
274        } else {
275            Self::InMemory(memory_table(fleet))
276        })
277    }
278
279    pub(crate) fn allocate(
280        &self,
281        text: &str,
282    ) -> Result<Text> {
283        if text.len() > CELL_TEXT_MAX {
284            return Err(Error::TooLong { len: text.len(), max: CELL_TEXT_MAX });
285        }
286        let (id, lease) = self.with_structure(|slots, hint, owners| {
287            let capacity = slots.len();
288            let start = hint.load(Ordering::Relaxed) as usize;
289            let allocate = || -> Result<Option<(TextId, Arc<Lease>)>> {
290                for offset in 0..capacity {
291                    let index = (start + offset) & (capacity - 1);
292                    let slot = &slots[index];
293                    if matches!(
294                        slot.state.load(Ordering::Acquire),
295                        SLOT_EMPTY | SLOT_RETIRED
296                    ) && slot.generation.load(Ordering::Relaxed) != u32::MAX
297                        && slot.writer.load(Ordering::Acquire) == 0
298                        && !owners.pinned(index)
299                    {
300                        let lease = owners.lease(index)?;
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(Some((
307                            TextId { index: index as u32, generation },
308                            lease,
309                        )));
310                    }
311                }
312                Ok(None)
313            };
314            if let Some(allocated) = allocate()? {
315                return Ok(allocated);
316            }
317            if self.reclaim_dead(owners)?
318                && let Some(allocated) = allocate()?
319            {
320                return Ok(allocated);
321            }
322            Err(Error::Full { capacity })
323        })?;
324        Ok(Text { table: self.clone(), id, _lease: lease })
325    }
326
327    pub(crate) fn open(
328        &self,
329        id: TextId,
330    ) -> Result<Text> {
331        let lease = self.with_structure(|slots, _, owners| {
332            slots
333                .get(id.index as usize)
334                .filter(|slot| slot.is(id))
335                .ok_or(Error::StaleText(id))?;
336            owners.lease(id.index as usize)
337        })?;
338        Ok(Text { table: self.clone(), id, _lease: lease })
339    }
340
341    pub(crate) fn release(
342        &self,
343        id: TextId,
344    ) -> Result<()> {
345        self.with_structure(|slots, _, _| {
346            let slot = slots
347                .get(id.index as usize)
348                .filter(|slot| slot.is(id))
349                .ok_or(Error::StaleText(id))?;
350            slot.state.store(SLOT_RETIRED, Ordering::SeqCst);
351            slot.wake.changed();
352            Ok(())
353        })
354    }
355
356    pub(crate) fn is_live(
357        &self,
358        id: TextId,
359    ) -> bool {
360        self.slot(id).is_ok()
361    }
362
363    pub(crate) fn reset_all(&self) -> Result<()> {
364        self.with_structure(|slots, hint, _| {
365            for slot in slots {
366                slot.state.store(SLOT_RETIRED, Ordering::SeqCst);
367                slot.wake.changed();
368            }
369            hint.store(0, Ordering::Relaxed);
370            Ok(())
371        })
372    }
373
374    #[cfg(unix)]
375    pub(crate) fn unlink(&self) -> Result<()> {
376        match self {
377            Self::InMemory(_) => self.reset_all(),
378            Self::Shm(table) => table.shared.region.unlink().map_err(Error::Io),
379        }
380    }
381
382    fn writer_token(&self) -> u64 {
383        match self {
384            Self::InMemory(_) => 1,
385            #[cfg(unix)]
386            Self::Shm(table) => table.token,
387        }
388    }
389
390    fn reclaim_dead(
391        &self,
392        owners: &Owners,
393    ) -> Result<bool> {
394        owners.reclaim_dead_with(|owner| {
395            #[cfg(unix)]
396            if let Self::Shm(table) = self {
397                owners::recover(&table.shared.region, owner)?;
398            }
399            #[cfg(not(unix))]
400            let _ = owner;
401            Ok(())
402        })
403    }
404
405    pub(crate) fn recover_writers(&self) -> Result<usize> {
406        let mut recovered = 0;
407        self.with_structure(|_, _, pins| {
408            pins.reclaim_dead_with(|owner| {
409                #[cfg(unix)]
410                if let Self::Shm(table) = self {
411                    recovered += owners::recover(&table.shared.region, owner)?;
412                }
413                #[cfg(not(unix))]
414                let _ = owner;
415                Ok(())
416            })?;
417            Ok(())
418        })?;
419        Ok(recovered)
420    }
421
422    fn slots(&self) -> &[TextSlot] {
423        match self {
424            Self::InMemory(table) => &table.slots,
425            #[cfg(unix)]
426            Self::Shm(table) => table.slots(),
427        }
428    }
429
430    fn slot(
431        &self,
432        id: TextId,
433    ) -> Result<&TextSlot> {
434        self.slots()
435            .get(id.index as usize)
436            .filter(|slot| slot.is(id))
437            .ok_or(Error::StaleText(id))
438    }
439
440    fn with_structure<T>(
441        &self,
442        operation: impl FnOnce(&[TextSlot], &AtomicU32, &Owners) -> Result<T>,
443    ) -> Result<T> {
444        match self {
445            Self::InMemory(table) => {
446                let _local = lock_unpoisoned(&table.structural_lock);
447                operation(&table.slots, &table.hint, &table.owners)
448            }
449            #[cfg(unix)]
450            Self::Shm(table) => table.with_structure(operation),
451        }
452    }
453}
454
455#[repr(C, align(64))]
456struct TextStateHeader {
457    #[cfg(unix)]
458    magic: u32,
459    #[cfg(not(unix))]
460    _magic: u32,
461    #[cfg(unix)]
462    version: u16,
463    #[cfg(not(unix))]
464    _version: u16,
465    header_size: u16,
466    capacity: u32,
467    slot_size: u32,
468    hint: AtomicU32,
469    owners: u32,
470    _reserved: [u8; 40],
471}
472
473impl TextStateHeader {
474    fn new(owners: usize) -> Self {
475        Self {
476            #[cfg(unix)]
477            magic: TEXT_MAGIC,
478            #[cfg(not(unix))]
479            _magic: 0,
480            #[cfg(unix)]
481            version: TEXT_VERSION,
482            #[cfg(not(unix))]
483            _version: 0,
484            header_size: size_of::<Self>() as u16,
485            capacity: CELL_TEXT_CAPACITY as u32,
486            slot_size: size_of::<TextSlot>() as u32,
487            hint: AtomicU32::new(0),
488            owners: owners as u32,
489            _reserved: [0; 40],
490        }
491    }
492}
493
494pub(crate) struct MemoryTextTable {
495    /// Held so the fleet's address, which keys the registry, cannot be
496    /// reused by another fleet while this table lives.
497    _fleet: Arc<Fleet>,
498    slots: Vec<TextSlot>,
499    hint: AtomicU32,
500    structural_lock: Mutex<()>,
501    owners: Owners,
502}
503
504impl MemoryTextTable {
505    fn new(fleet: Arc<Fleet>) -> Self {
506        Self {
507            _fleet: fleet,
508            slots: (0..CELL_TEXT_CAPACITY).map(|_| TextSlot::empty()).collect(),
509            hint: AtomicU32::new(0),
510            structural_lock: Mutex::new(()),
511            owners: Owners::memory(CELL_TEXT_CAPACITY),
512        }
513    }
514}
515
516type MemoryRegistry = HashMap<usize, Weak<MemoryTextTable>>;
517
518static MEMORY_TABLES: LazyLock<Mutex<MemoryRegistry>> =
519    LazyLock::new(|| Mutex::new(HashMap::new()));
520
521fn memory_table(fleet: &Arc<Fleet>) -> Arc<MemoryTextTable> {
522    let fleet_identity = Arc::as_ptr(fleet) as usize;
523    let mut tables = lock_unpoisoned(&MEMORY_TABLES);
524    tables.retain(|_, table| table.strong_count() > 0);
525    if let Some(table) = tables.get(&fleet_identity).and_then(Weak::upgrade) {
526        return table;
527    }
528    let table = Arc::new(MemoryTextTable::new(Arc::clone(fleet)));
529    tables.insert(fleet_identity, Arc::downgrade(&table));
530    table
531}
532
533#[cfg(unix)]
534pub(crate) struct ShmTextTable {
535    shared: Arc<lifetime::Shared>,
536    token: u64,
537    structural_lock: Mutex<()>,
538}
539
540#[cfg(unix)]
541impl ShmTextTable {
542    fn open_or_create(
543        name: &str,
544        owners: usize,
545    ) -> Result<Self> {
546        use std::{io, ptr};
547
548        let (region, _initialization_lock) =
549            ShmRegion::open_or_create_locked(name, shm_segment_size(owners)?)?;
550        if region.created() {
551            unsafe {
552                ptr::write(
553                    region.as_ptr().cast::<TextStateHeader>(),
554                    TextStateHeader::new(owners),
555                );
556                let slots = region.as_ptr().add(size_of::<TextStateHeader>());
557                ptr::write_bytes(
558                    slots,
559                    0,
560                    shm_segment_size(owners)? - size_of::<TextStateHeader>(),
561                );
562            }
563        } else {
564            let header = unsafe { &*region.as_ptr().cast::<TextStateHeader>() };
565            if header.magic != TEXT_MAGIC {
566                return Err(Error::Io(io::Error::new(
567                    io::ErrorKind::InvalidData,
568                    format!(
569                        "SHM segment {name} has wrong magic 0x{:08X} (expected 0x{TEXT_MAGIC:08X})",
570                        header.magic
571                    ),
572                )));
573            }
574            if header.version != TEXT_VERSION
575                || usize::from(header.header_size)
576                    != size_of::<TextStateHeader>()
577                || header.capacity as usize != CELL_TEXT_CAPACITY
578                || header.slot_size as usize != size_of::<TextSlot>()
579                || header.owners as usize != owners
580            {
581                return Err(Error::Io(io::Error::new(
582                    io::ErrorKind::InvalidData,
583                    format!(
584                        "SHM segment {name} has an incompatible text-cell layout"
585                    ),
586                )));
587            }
588        }
589        let shared = lifetime::Shared::claim_locked_with(
590            region,
591            name,
592            owners,
593            CELL_TEXT_CAPACITY,
594            owners::pins_offset(owners)?,
595            |owner, region| owners::claim(region, owner),
596        )?;
597        let token = owners::token(&shared.region, shared.owner());
598        Ok(Self { shared, token, structural_lock: Mutex::new(()) })
599    }
600
601    fn header(&self) -> &TextStateHeader {
602        unsafe { &*self.shared.region.as_ptr().cast::<TextStateHeader>() }
603    }
604
605    fn slots(&self) -> &[TextSlot] {
606        owners::slots(&self.shared.region)
607    }
608
609    fn with_structure<T>(
610        &self,
611        operation: impl FnOnce(&[TextSlot], &AtomicU32, &Owners) -> Result<T>,
612    ) -> Result<T> {
613        let owners = Owners::Shared(self.shared.clone());
614        owners.check_process()?;
615        let _local = lock_unpoisoned(&self.structural_lock);
616        let _process = self.shared.region.lock_exclusive()?;
617        operation(self.slots(), &self.header().hint, &owners)
618    }
619}
620
621#[cfg(unix)]
622fn shm_segment_size(owners: usize) -> Result<usize> {
623    lifetime::segment_size(
624        owners::pins_offset(owners)?,
625        CELL_TEXT_CAPACITY,
626        owners,
627    )
628}
629
630#[cfg(unix)]
631fn shm_table(
632    name: &str,
633    owners: usize,
634) -> Result<Arc<ShmTextTable>> {
635    static TABLES: LazyLock<Mutex<HashMap<String, Weak<ShmTextTable>>>> =
636        LazyLock::new(|| Mutex::new(HashMap::new()));
637    let mut tables = lock_unpoisoned(&TABLES);
638    tables.retain(|_, table| table.strong_count() != 0);
639    if let Some(table) = tables.get(name).and_then(Weak::upgrade)
640        && table.shared.is_current(owners)
641    {
642        return Ok(table);
643    }
644    let table = Arc::new(ShmTextTable::open_or_create(name, owners)?);
645    tables.insert(name.to_owned(), Arc::downgrade(&table));
646    Ok(table)
647}
648
649const _: () = assert!(CELL_TEXT_CAPACITY.is_power_of_two());
650const _: () = assert!(CELL_TEXT_MAX > 0 && CELL_TEXT_MAX <= u16::MAX as usize);
651const _: () = assert!(size_of::<TextStateHeader>() == 64);