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    pub fn len(&self) -> Result<usize> {
126        self.slot()?.copy(self.id, &mut [])
127    }
128
129    pub fn is_empty(&self) -> Result<bool> {
130        Ok(self.len()? == 0)
131    }
132
133    /// Replace the text. A live writer causes [`Error::Contended`], not a spin.
134    pub fn store(
135        &self,
136        text: &str,
137    ) -> Result<()> {
138        if text.len() > CELL_TEXT_MAX {
139            return Err(Error::TooLong { len: text.len(), max: CELL_TEXT_MAX });
140        }
141        let slot = self.slot()?;
142        slot.writer(self.table.writer_token())?
143            .write(text.as_bytes(), false)?;
144        Ok(())
145    }
146
147    /// Append by publishing a complete new bank. A refused write changes
148    /// neither the committed value nor its revision.
149    pub fn append(
150        &self,
151        text: &str,
152    ) -> Result<usize> {
153        self.slot()?
154            .writer(self.table.writer_token())?
155            .write(text.as_bytes(), true)
156    }
157
158    /// Retire this generation immediately. The physical slot remains pinned
159    /// until every handle to the old generation has been dropped.
160    pub fn release(self) -> Result<()> {
161        self.table.release(self.id)
162    }
163
164    /// The full-width committed revision token. Compare for equality; do not
165    /// truncate it or interpret its bank bit as an in-progress marker.
166    pub fn version(&self) -> Result<u64> {
167        Ok(self.slot()?.committed.load(Ordering::Acquire))
168    }
169
170    /// Park until the text has been written since `since`, then return the
171    /// sequence now. A released cell wakes every waiter with
172    /// [`Error::StaleText`]. Blocking; an async runtime wraps it.
173    pub fn wait_changed(
174        &self,
175        since: u64,
176    ) -> Result<u64> {
177        loop {
178            let slot = self.slot()?;
179            let now = slot.committed.load(Ordering::SeqCst);
180            if now != since {
181                return Ok(now);
182            }
183            slot.wake.wait_if(|| {
184                slot.state.load(Ordering::SeqCst) == SLOT_OCCUPIED
185                    && slot.committed.load(Ordering::SeqCst) == since
186            })?;
187        }
188    }
189
190    /// [`Self::wait_changed`], but a second word can end it. `Ok(None)`
191    /// means the interrupt arrived, not that time ran out — nothing here
192    /// measures time. The canceller sets `interrupt`, then calls
193    /// [`Self::wake_waiters`]. The wake advances a separate parking generation;
194    /// it does not change the text or its committed revision.
195    pub fn wait_changed_until(
196        &self,
197        since: u64,
198        interrupt: &core::sync::atomic::AtomicBool,
199    ) -> Result<Option<u64>> {
200        loop {
201            if interrupt.load(Ordering::SeqCst) {
202                return Ok(None);
203            }
204
205            let slot = self.slot()?;
206            let now = slot.committed.load(Ordering::SeqCst);
207            if now != since {
208                return Ok(Some(now));
209            }
210
211            slot.wake.wait_if(|| {
212                slot.state.load(Ordering::SeqCst) == SLOT_OCCUPIED
213                    && slot.committed.load(Ordering::SeqCst) == since
214                    && !interrupt.load(Ordering::SeqCst)
215            })?;
216        }
217    }
218
219    /// Wake everyone parked on this text cell without appending to it.
220    /// Only useful after the word the waiter also watches has been set.
221    pub fn wake_waiters(&self) -> Result<()> {
222        self.slot()?.wake.notify();
223        Ok(())
224    }
225
226    fn slot(&self) -> Result<&TextSlot> {
227        self.table.slot(self.id)
228    }
229}
230
231#[derive(Clone)]
232pub(crate) enum TextBackend {
233    InMemory(Arc<MemoryTextTable>),
234    #[cfg(unix)]
235    Shm(Arc<ShmTextTable>),
236}
237
238impl TextBackend {
239    pub(crate) fn new(fleet: &Arc<Fleet>) -> Result<Self> {
240        Ok(if fleet.is_shm() {
241            #[cfg(unix)]
242            {
243                Self::Shm(shm_table(
244                    &ring_segment_name(fleet.name(), CELL_TEXT_KIND),
245                    lifetime::owner_capacity(fleet),
246                )?)
247            }
248            #[cfg(not(unix))]
249            unreachable!("non-Unix fleets cannot use POSIX SHM")
250        } else {
251            Self::InMemory(memory_table(fleet))
252        })
253    }
254
255    pub(crate) fn allocate(
256        &self,
257        text: &str,
258    ) -> Result<Text> {
259        if text.len() > CELL_TEXT_MAX {
260            return Err(Error::TooLong { len: text.len(), max: CELL_TEXT_MAX });
261        }
262        let (id, lease) = self.with_structure(|slots, hint, owners| {
263            let capacity = slots.len();
264            let start = hint.load(Ordering::Relaxed) as usize;
265            let allocate = || -> Result<Option<(TextId, Arc<Lease>)>> {
266                for offset in 0..capacity {
267                    let index = (start + offset) & (capacity - 1);
268                    let slot = &slots[index];
269                    if matches!(
270                        slot.state.load(Ordering::Acquire),
271                        SLOT_EMPTY | SLOT_RETIRED
272                    ) && slot.generation.load(Ordering::Relaxed) != u32::MAX
273                        && slot.writer.load(Ordering::Acquire) == 0
274                        && !owners.pinned(index)
275                    {
276                        let lease = owners.lease(index)?;
277                        let generation = slot.install(text.as_bytes());
278                        hint.store(
279                            ((index + 1) & (capacity - 1)) as u32,
280                            Ordering::Relaxed,
281                        );
282                        return Ok(Some((
283                            TextId { index: index as u32, generation },
284                            lease,
285                        )));
286                    }
287                }
288                Ok(None)
289            };
290            if let Some(allocated) = allocate()? {
291                return Ok(allocated);
292            }
293            if self.reclaim_dead(owners)?
294                && let Some(allocated) = allocate()?
295            {
296                return Ok(allocated);
297            }
298            Err(Error::Full { capacity })
299        })?;
300        Ok(Text { table: self.clone(), id, _lease: lease })
301    }
302
303    pub(crate) fn open(
304        &self,
305        id: TextId,
306    ) -> Result<Text> {
307        let lease = self.with_structure(|slots, _, owners| {
308            slots
309                .get(id.index as usize)
310                .filter(|slot| slot.is(id))
311                .ok_or(Error::StaleText(id))?;
312            owners.lease(id.index as usize)
313        })?;
314        Ok(Text { table: self.clone(), id, _lease: lease })
315    }
316
317    pub(crate) fn release(
318        &self,
319        id: TextId,
320    ) -> Result<()> {
321        self.with_structure(|slots, _, _| {
322            let slot = slots
323                .get(id.index as usize)
324                .filter(|slot| slot.is(id))
325                .ok_or(Error::StaleText(id))?;
326            slot.state.store(SLOT_RETIRED, Ordering::SeqCst);
327            slot.wake.changed();
328            Ok(())
329        })
330    }
331
332    pub(crate) fn is_live(
333        &self,
334        id: TextId,
335    ) -> bool {
336        self.slot(id).is_ok()
337    }
338
339    pub(crate) fn reset_all(&self) -> Result<()> {
340        self.with_structure(|slots, hint, _| {
341            for slot in slots {
342                slot.state.store(SLOT_RETIRED, Ordering::SeqCst);
343                slot.wake.changed();
344            }
345            hint.store(0, Ordering::Relaxed);
346            Ok(())
347        })
348    }
349
350    #[cfg(unix)]
351    pub(crate) fn unlink(&self) -> Result<()> {
352        match self {
353            Self::InMemory(_) => self.reset_all(),
354            Self::Shm(table) => table.shared.region.unlink().map_err(Error::Io),
355        }
356    }
357
358    fn writer_token(&self) -> u64 {
359        match self {
360            Self::InMemory(_) => 1,
361            #[cfg(unix)]
362            Self::Shm(table) => table.token,
363        }
364    }
365
366    fn reclaim_dead(
367        &self,
368        owners: &Owners,
369    ) -> Result<bool> {
370        owners.reclaim_dead_with(|owner| {
371            #[cfg(unix)]
372            if let Self::Shm(table) = self {
373                owners::recover(&table.shared.region, owner)?;
374            }
375            #[cfg(not(unix))]
376            let _ = owner;
377            Ok(())
378        })
379    }
380
381    pub(crate) fn recover_writers(&self) -> Result<usize> {
382        let mut recovered = 0;
383        self.with_structure(|_, _, pins| {
384            pins.reclaim_dead_with(|owner| {
385                #[cfg(unix)]
386                if let Self::Shm(table) = self {
387                    recovered += owners::recover(&table.shared.region, owner)?;
388                }
389                #[cfg(not(unix))]
390                let _ = owner;
391                Ok(())
392            })?;
393            Ok(())
394        })?;
395        Ok(recovered)
396    }
397
398    fn slots(&self) -> &[TextSlot] {
399        match self {
400            Self::InMemory(table) => &table.slots,
401            #[cfg(unix)]
402            Self::Shm(table) => table.slots(),
403        }
404    }
405
406    fn slot(
407        &self,
408        id: TextId,
409    ) -> Result<&TextSlot> {
410        self.slots()
411            .get(id.index as usize)
412            .filter(|slot| slot.is(id))
413            .ok_or(Error::StaleText(id))
414    }
415
416    fn with_structure<T>(
417        &self,
418        operation: impl FnOnce(&[TextSlot], &AtomicU32, &Owners) -> Result<T>,
419    ) -> Result<T> {
420        match self {
421            Self::InMemory(table) => {
422                let _local = lock_unpoisoned(&table.structural_lock);
423                operation(&table.slots, &table.hint, &table.owners)
424            }
425            #[cfg(unix)]
426            Self::Shm(table) => table.with_structure(operation),
427        }
428    }
429}
430
431#[repr(C, align(64))]
432struct TextStateHeader {
433    #[cfg(unix)]
434    magic: u32,
435    #[cfg(not(unix))]
436    _magic: u32,
437    #[cfg(unix)]
438    version: u16,
439    #[cfg(not(unix))]
440    _version: u16,
441    header_size: u16,
442    capacity: u32,
443    slot_size: u32,
444    hint: AtomicU32,
445    owners: u32,
446    _reserved: [u8; 40],
447}
448
449impl TextStateHeader {
450    fn new(owners: usize) -> Self {
451        Self {
452            #[cfg(unix)]
453            magic: TEXT_MAGIC,
454            #[cfg(not(unix))]
455            _magic: 0,
456            #[cfg(unix)]
457            version: TEXT_VERSION,
458            #[cfg(not(unix))]
459            _version: 0,
460            header_size: size_of::<Self>() as u16,
461            capacity: CELL_TEXT_CAPACITY as u32,
462            slot_size: size_of::<TextSlot>() as u32,
463            hint: AtomicU32::new(0),
464            owners: owners as u32,
465            _reserved: [0; 40],
466        }
467    }
468}
469
470pub(crate) struct MemoryTextTable {
471    /// Held so the fleet's address, which keys the registry, cannot be
472    /// reused by another fleet while this table lives.
473    _fleet: Arc<Fleet>,
474    slots: Vec<TextSlot>,
475    hint: AtomicU32,
476    structural_lock: Mutex<()>,
477    owners: Owners,
478}
479
480impl MemoryTextTable {
481    fn new(fleet: Arc<Fleet>) -> Self {
482        Self {
483            _fleet: fleet,
484            slots: (0..CELL_TEXT_CAPACITY).map(|_| TextSlot::empty()).collect(),
485            hint: AtomicU32::new(0),
486            structural_lock: Mutex::new(()),
487            owners: Owners::memory(CELL_TEXT_CAPACITY),
488        }
489    }
490}
491
492type MemoryRegistry = HashMap<usize, Weak<MemoryTextTable>>;
493
494static MEMORY_TABLES: LazyLock<Mutex<MemoryRegistry>> =
495    LazyLock::new(|| Mutex::new(HashMap::new()));
496
497fn memory_table(fleet: &Arc<Fleet>) -> Arc<MemoryTextTable> {
498    let fleet_identity = Arc::as_ptr(fleet) as usize;
499    let mut tables = lock_unpoisoned(&MEMORY_TABLES);
500    tables.retain(|_, table| table.strong_count() > 0);
501    if let Some(table) = tables.get(&fleet_identity).and_then(Weak::upgrade) {
502        return table;
503    }
504    let table = Arc::new(MemoryTextTable::new(Arc::clone(fleet)));
505    tables.insert(fleet_identity, Arc::downgrade(&table));
506    table
507}
508
509#[cfg(unix)]
510pub(crate) struct ShmTextTable {
511    shared: Arc<lifetime::Shared>,
512    token: u64,
513    structural_lock: Mutex<()>,
514}
515
516#[cfg(unix)]
517impl ShmTextTable {
518    fn open_or_create(
519        name: &str,
520        owners: usize,
521    ) -> Result<Self> {
522        use std::{io, ptr};
523
524        let (region, _initialization_lock) =
525            ShmRegion::open_or_create_locked(name, shm_segment_size(owners)?)?;
526        if region.created() {
527            unsafe {
528                ptr::write(
529                    region.as_ptr().cast::<TextStateHeader>(),
530                    TextStateHeader::new(owners),
531                );
532                let slots = region.as_ptr().add(size_of::<TextStateHeader>());
533                ptr::write_bytes(
534                    slots,
535                    0,
536                    shm_segment_size(owners)? - size_of::<TextStateHeader>(),
537                );
538            }
539        } else {
540            let header = unsafe { &*region.as_ptr().cast::<TextStateHeader>() };
541            if header.magic != TEXT_MAGIC {
542                return Err(Error::Io(io::Error::new(
543                    io::ErrorKind::InvalidData,
544                    format!(
545                        "SHM segment {name} has wrong magic 0x{:08X} (expected 0x{TEXT_MAGIC:08X})",
546                        header.magic
547                    ),
548                )));
549            }
550            if header.version != TEXT_VERSION
551                || usize::from(header.header_size)
552                    != size_of::<TextStateHeader>()
553                || header.capacity as usize != CELL_TEXT_CAPACITY
554                || header.slot_size as usize != size_of::<TextSlot>()
555                || header.owners as usize != owners
556            {
557                return Err(Error::Io(io::Error::new(
558                    io::ErrorKind::InvalidData,
559                    format!(
560                        "SHM segment {name} has an incompatible text-cell layout"
561                    ),
562                )));
563            }
564        }
565        let shared = lifetime::Shared::claim_locked_with(
566            region,
567            name,
568            owners,
569            CELL_TEXT_CAPACITY,
570            owners::pins_offset(owners)?,
571            |owner, region| owners::claim(region, owner),
572        )?;
573        let token = owners::token(&shared.region, shared.owner());
574        Ok(Self { shared, token, structural_lock: Mutex::new(()) })
575    }
576
577    fn header(&self) -> &TextStateHeader {
578        unsafe { &*self.shared.region.as_ptr().cast::<TextStateHeader>() }
579    }
580
581    fn slots(&self) -> &[TextSlot] {
582        owners::slots(&self.shared.region)
583    }
584
585    fn with_structure<T>(
586        &self,
587        operation: impl FnOnce(&[TextSlot], &AtomicU32, &Owners) -> Result<T>,
588    ) -> Result<T> {
589        let owners = Owners::Shared(self.shared.clone());
590        owners.check_process()?;
591        let _local = lock_unpoisoned(&self.structural_lock);
592        let _process = self.shared.region.lock_exclusive()?;
593        operation(self.slots(), &self.header().hint, &owners)
594    }
595}
596
597#[cfg(unix)]
598fn shm_segment_size(owners: usize) -> Result<usize> {
599    lifetime::segment_size(
600        owners::pins_offset(owners)?,
601        CELL_TEXT_CAPACITY,
602        owners,
603    )
604}
605
606#[cfg(unix)]
607fn shm_table(
608    name: &str,
609    owners: usize,
610) -> Result<Arc<ShmTextTable>> {
611    static TABLES: LazyLock<Mutex<HashMap<String, Weak<ShmTextTable>>>> =
612        LazyLock::new(|| Mutex::new(HashMap::new()));
613    let mut tables = lock_unpoisoned(&TABLES);
614    tables.retain(|_, table| table.strong_count() != 0);
615    if let Some(table) = tables.get(name).and_then(Weak::upgrade)
616        && table.shared.is_current(owners)
617    {
618        return Ok(table);
619    }
620    let table = Arc::new(ShmTextTable::open_or_create(name, owners)?);
621    tables.insert(name.to_owned(), Arc::downgrade(&table));
622    Ok(table)
623}
624
625const _: () = assert!(CELL_TEXT_CAPACITY.is_power_of_two());
626const _: () = assert!(CELL_TEXT_MAX > 0 && CELL_TEXT_MAX <= u16::MAX as usize);
627const _: () = assert!(size_of::<TextStateHeader>() == 64);