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 =
27    orbit_core::compile::usize_from_env(option_env!("ORBIT_CELL_TEXT_CAPACITY"), 1_024);
28/// Bytes one text cell can hold.
29///
30/// Compile-time geometry: `ORBIT_CELL_TEXT_MAX`. It sizes every slot, so it is
31/// part of the same wire contract as the capacity.
32pub const CELL_TEXT_MAX: usize =
33    orbit_core::compile::usize_from_env(option_env!("ORBIT_CELL_TEXT_MAX"), 240);
34
35#[cfg(unix)]
36const TEXT_MAGIC: u32 = 0x43_54_58_54; // "CTXT"
37#[cfg(unix)]
38const TEXT_VERSION: u16 = 1;
39
40/// The address of one text cell; prints as `text:<slot>:<generation>`.
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
42pub struct TextId {
43    index: u32,
44    generation: u32,
45}
46
47impl TextId {
48    pub const fn index(self) -> u32 {
49        self.index
50    }
51
52    pub const fn generation(self) -> u32 {
53        self.generation
54    }
55
56    pub const fn to_bits(self) -> u64 {
57        ((self.index as u64) << 32) | self.generation as u64
58    }
59
60    pub const fn from_bits(bits: u64) -> Self {
61        Self {
62            index: (bits >> 32) as u32,
63            generation: bits as u32,
64        }
65    }
66}
67
68impl fmt::Display for TextId {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        write!(f, "text:{}:{}", self.index, self.generation)
71    }
72}
73
74impl FromStr for TextId {
75    type Err = Error;
76
77    fn from_str(text: &str) -> Result<Self> {
78        let malformed = || Error::Malformed(text.to_owned());
79        let rest = text.strip_prefix("text:").ok_or_else(malformed)?;
80        let (index, generation) = rest.split_once(':').ok_or_else(malformed)?;
81        Ok(Self {
82            index: index.parse().map_err(|_| malformed())?,
83            generation: generation.parse().map_err(|_| malformed())?,
84        })
85    }
86}
87
88/// One text cell. Cheap to clone; every clone is the same place.
89#[derive(Clone)]
90pub struct Text {
91    table: TextBackend,
92    id: TextId,
93}
94
95impl Text {
96    pub fn id(&self) -> TextId {
97        self.id
98    }
99
100    /// A copy of the current text.
101    pub fn load(&self) -> Result<String> {
102        let slot = self.slot()?;
103        let mut bytes = vec![0_u8; CELL_TEXT_MAX];
104        loop {
105            let before = slot.version.load(Ordering::Acquire);
106            if before & 1 == 1 {
107                std::hint::spin_loop();
108                continue;
109            }
110            let len = usize::from(slot.len.load(Ordering::Relaxed)).min(CELL_TEXT_MAX);
111            for (target, source) in bytes[..len].iter_mut().zip(&slot.bytes[..len]) {
112                *target = source.load(Ordering::Relaxed);
113            }
114            std::sync::atomic::fence(Ordering::Acquire);
115            if slot.version.load(Ordering::Relaxed) == before {
116                bytes.truncate(len);
117                return String::from_utf8(bytes).map_err(|_| Error::Corrupt(self.id.to_string()));
118            }
119        }
120    }
121
122    pub fn len(&self) -> Result<usize> {
123        Ok(usize::from(self.slot()?.len.load(Ordering::Acquire)))
124    }
125
126    pub fn is_empty(&self) -> Result<bool> {
127        Ok(self.len()? == 0)
128    }
129
130    /// Replace the text.
131    pub fn store(&self, text: &str) -> Result<()> {
132        if text.len() > CELL_TEXT_MAX {
133            return Err(Error::TooLong {
134                len: text.len(),
135                max: CELL_TEXT_MAX,
136            });
137        }
138        let slot = self.slot()?;
139        let _write = slot.writer();
140        slot.write(0, text.as_bytes());
141        Ok(())
142    }
143
144    /// Append to the text, atomically with respect to every other writer;
145    /// refused whole, never cut, when the result would not fit.
146    pub fn append(&self, text: &str) -> Result<usize> {
147        let slot = self.slot()?;
148        let _write = slot.writer();
149        let current = usize::from(slot.len.load(Ordering::Relaxed));
150        let total = current + text.len();
151        if total > CELL_TEXT_MAX {
152            return Err(Error::TooLong {
153                len: total,
154                max: CELL_TEXT_MAX,
155            });
156        }
157        slot.write(current, text.as_bytes());
158        Ok(total)
159    }
160
161    /// Give the cell back; this and every other handle to it go stale.
162    pub fn release(self) -> Result<()> {
163        self.table.release(self.id)
164    }
165
166    fn slot(&self) -> Result<&TextSlot> {
167        self.table.slot(self.id)
168    }
169}
170
171#[derive(Clone)]
172pub(crate) enum TextBackend {
173    InMemory(Arc<MemoryTextTable>),
174    #[cfg(unix)]
175    Shm(Arc<ShmTextTable>),
176}
177
178impl TextBackend {
179    pub(crate) fn new(fleet: &Arc<Fleet>) -> Result<Self> {
180        Ok(if fleet.is_shm() {
181            #[cfg(unix)]
182            {
183                Self::Shm(Arc::new(ShmTextTable::open_or_create(&ring_segment_name(
184                    fleet.name(),
185                    CELL_TEXT_KIND,
186                ))?))
187            }
188            #[cfg(not(unix))]
189            unreachable!("non-Unix fleets cannot use POSIX SHM")
190        } else {
191            Self::InMemory(memory_table(fleet))
192        })
193    }
194
195    pub(crate) fn allocate(&self, text: &str) -> Result<Text> {
196        if text.len() > CELL_TEXT_MAX {
197            return Err(Error::TooLong {
198                len: text.len(),
199                max: CELL_TEXT_MAX,
200            });
201        }
202        let id = self.with_structure(|slots, hint| {
203            let capacity = slots.len();
204            let start = hint.load(Ordering::Relaxed) as usize;
205            for offset in 0..capacity {
206                let index = (start + offset) & (capacity - 1);
207                let slot = &slots[index];
208                if slot.state.load(Ordering::Acquire) == SLOT_EMPTY {
209                    let generation = slot.install(text.as_bytes());
210                    hint.store(((index + 1) & (capacity - 1)) as u32, Ordering::Relaxed);
211                    return Ok(TextId {
212                        index: index as u32,
213                        generation,
214                    });
215                }
216            }
217            Err(Error::Full { capacity })
218        })?;
219        Ok(Text {
220            table: self.clone(),
221            id,
222        })
223    }
224
225    pub(crate) fn open(&self, id: TextId) -> Result<Text> {
226        self.slot(id)?;
227        Ok(Text {
228            table: self.clone(),
229            id,
230        })
231    }
232
233    pub(crate) fn release(&self, id: TextId) -> Result<()> {
234        self.with_structure(|slots, _| {
235            let slot = slots
236                .get(id.index as usize)
237                .filter(|slot| slot.is(id))
238                .ok_or(Error::StaleText(id))?;
239            slot.state.store(SLOT_EMPTY, Ordering::Release);
240            Ok(())
241        })
242    }
243
244    pub(crate) fn is_live(&self, id: TextId) -> bool {
245        self.slot(id).is_ok()
246    }
247
248    pub(crate) fn reset_all(&self) -> Result<()> {
249        self.with_structure(|slots, hint| {
250            for slot in slots {
251                slot.state.store(SLOT_EMPTY, Ordering::Release);
252            }
253            hint.store(0, Ordering::Relaxed);
254            Ok(())
255        })
256    }
257
258    #[cfg(unix)]
259    pub(crate) fn unlink(&self) -> Result<()> {
260        match self {
261            Self::InMemory(_) => self.reset_all(),
262            Self::Shm(table) => table.region.unlink().map_err(Error::Io),
263        }
264    }
265
266    fn slots(&self) -> &[TextSlot] {
267        match self {
268            Self::InMemory(table) => &table.slots,
269            #[cfg(unix)]
270            Self::Shm(table) => table.slots(),
271        }
272    }
273
274    fn slot(&self, id: TextId) -> Result<&TextSlot> {
275        self.slots()
276            .get(id.index as usize)
277            .filter(|slot| slot.is(id))
278            .ok_or(Error::StaleText(id))
279    }
280
281    fn with_structure<T>(
282        &self,
283        operation: impl FnOnce(&[TextSlot], &AtomicU32) -> Result<T>,
284    ) -> Result<T> {
285        match self {
286            Self::InMemory(table) => {
287                let _local = lock_unpoisoned(&table.structural_lock);
288                operation(&table.slots, &table.hint)
289            }
290            #[cfg(unix)]
291            Self::Shm(table) => table.with_structure(operation),
292        }
293    }
294}
295
296#[repr(C, align(64))]
297struct TextStateHeader {
298    #[cfg(unix)]
299    magic: u32,
300    #[cfg(not(unix))]
301    _magic: u32,
302    #[cfg(unix)]
303    version: u16,
304    #[cfg(not(unix))]
305    _version: u16,
306    header_size: u16,
307    capacity: u32,
308    slot_size: u32,
309    hint: AtomicU32,
310    _reserved: [u8; 44],
311}
312
313impl TextStateHeader {
314    fn new() -> Self {
315        Self {
316            #[cfg(unix)]
317            magic: TEXT_MAGIC,
318            #[cfg(not(unix))]
319            _magic: 0,
320            #[cfg(unix)]
321            version: TEXT_VERSION,
322            #[cfg(not(unix))]
323            _version: 0,
324            header_size: size_of::<Self>() as u16,
325            capacity: CELL_TEXT_CAPACITY as u32,
326            slot_size: size_of::<TextSlot>() as u32,
327            hint: AtomicU32::new(0),
328            _reserved: [0; 44],
329        }
330    }
331}
332
333/// One text cell in shared memory: a seqlock version, a writer bit, a
334/// length and the bytes, padded to the cache line.
335#[repr(C, align(64))]
336struct TextSlot {
337    state: AtomicU8,
338    writer: AtomicU8,
339    len: AtomicU16,
340    generation: AtomicU32,
341    version: AtomicU32,
342    _reserved: [u8; 4],
343    bytes: [AtomicU8; CELL_TEXT_MAX],
344}
345
346impl TextSlot {
347    fn empty() -> Self {
348        Self {
349            state: AtomicU8::new(SLOT_EMPTY),
350            writer: AtomicU8::new(0),
351            len: AtomicU16::new(0),
352            generation: AtomicU32::new(0),
353            version: AtomicU32::new(0),
354            _reserved: [0; 4],
355            bytes: std::array::from_fn(|_| AtomicU8::new(0)),
356        }
357    }
358
359    fn is(&self, id: TextId) -> bool {
360        self.state.load(Ordering::Acquire) == SLOT_OCCUPIED
361            && self.generation.load(Ordering::Relaxed) == id.generation
362    }
363
364    /// Under the structural lock.
365    fn install(&self, text: &[u8]) -> u32 {
366        let generation = self
367            .generation
368            .load(Ordering::Relaxed)
369            .wrapping_add(1)
370            .max(1);
371        for (index, byte) in self.bytes.iter().enumerate() {
372            byte.store(text.get(index).copied().unwrap_or(0), Ordering::Relaxed);
373        }
374        self.len.store(text.len() as u16, Ordering::Relaxed);
375        self.version.store(0, Ordering::Relaxed);
376        self.writer.store(0, Ordering::Relaxed);
377        self.generation.store(generation, Ordering::Relaxed);
378        self.state.store(SLOT_OCCUPIED, Ordering::Release);
379        generation
380    }
381
382    /// The cell's writer bit, held for the duration of one write.
383    fn writer(&self) -> WriterGuard<'_> {
384        while self
385            .writer
386            .compare_exchange_weak(0, 1, Ordering::Acquire, Ordering::Relaxed)
387            .is_err()
388        {
389            std::hint::spin_loop();
390        }
391        WriterGuard(self)
392    }
393
394    /// Write `bytes` at `offset` and publish the new length; the caller holds
395    /// the writer bit and has checked the fit.
396    fn write(&self, offset: usize, bytes: &[u8]) {
397        debug_assert!(offset + bytes.len() <= CELL_TEXT_MAX);
398        self.version.fetch_add(1, Ordering::AcqRel);
399        for (target, source) in self.bytes[offset..offset + bytes.len()].iter().zip(bytes) {
400            target.store(*source, Ordering::Relaxed);
401        }
402        self.len
403            .store((offset + bytes.len()) as u16, Ordering::Relaxed);
404        self.version.fetch_add(1, Ordering::AcqRel);
405    }
406}
407
408struct WriterGuard<'a>(&'a TextSlot);
409
410impl Drop for WriterGuard<'_> {
411    fn drop(&mut self) {
412        self.0.writer.store(0, Ordering::Release);
413    }
414}
415
416pub(crate) struct MemoryTextTable {
417    slots: Vec<TextSlot>,
418    hint: AtomicU32,
419    structural_lock: Mutex<()>,
420}
421
422impl MemoryTextTable {
423    fn new() -> Self {
424        Self {
425            slots: (0..CELL_TEXT_CAPACITY).map(|_| TextSlot::empty()).collect(),
426            hint: AtomicU32::new(0),
427            structural_lock: Mutex::new(()),
428        }
429    }
430}
431
432type MemoryRegistry = HashMap<usize, Weak<MemoryTextTable>>;
433
434static MEMORY_TABLES: LazyLock<Mutex<MemoryRegistry>> =
435    LazyLock::new(|| Mutex::new(HashMap::new()));
436
437fn memory_table(fleet: &Arc<Fleet>) -> Arc<MemoryTextTable> {
438    let fleet_identity = Arc::as_ptr(fleet) as usize;
439    let mut tables = lock_unpoisoned(&MEMORY_TABLES);
440    tables.retain(|_, table| table.strong_count() > 0);
441    if let Some(table) = tables.get(&fleet_identity).and_then(Weak::upgrade) {
442        return table;
443    }
444    let table = Arc::new(MemoryTextTable::new());
445    tables.insert(fleet_identity, Arc::downgrade(&table));
446    table
447}
448
449#[cfg(unix)]
450pub(crate) struct ShmTextTable {
451    region: ShmRegion,
452    structural_lock: Mutex<()>,
453}
454
455#[cfg(unix)]
456impl ShmTextTable {
457    fn open_or_create(name: &str) -> Result<Self> {
458        use std::{io, ptr};
459
460        let (region, _initialization_lock) =
461            ShmRegion::open_or_create_locked(name, shm_segment_size())?;
462        if region.created() {
463            unsafe {
464                ptr::write(
465                    region.as_ptr().cast::<TextStateHeader>(),
466                    TextStateHeader::new(),
467                );
468                let slots = region.as_ptr().add(size_of::<TextStateHeader>());
469                ptr::write_bytes(slots, 0, CELL_TEXT_CAPACITY * size_of::<TextSlot>());
470            }
471        } else {
472            let header = unsafe { &*region.as_ptr().cast::<TextStateHeader>() };
473            if header.magic != TEXT_MAGIC {
474                return Err(Error::Io(io::Error::new(
475                    io::ErrorKind::InvalidData,
476                    format!(
477                        "SHM segment {name} has wrong magic 0x{:08X} (expected 0x{TEXT_MAGIC:08X})",
478                        header.magic
479                    ),
480                )));
481            }
482            if header.version != TEXT_VERSION
483                || usize::from(header.header_size) != size_of::<TextStateHeader>()
484                || header.capacity as usize != CELL_TEXT_CAPACITY
485                || header.slot_size as usize != size_of::<TextSlot>()
486            {
487                return Err(Error::Io(io::Error::new(
488                    io::ErrorKind::InvalidData,
489                    format!("SHM segment {name} has an incompatible text-cell layout"),
490                )));
491            }
492        }
493        Ok(Self {
494            region,
495            structural_lock: Mutex::new(()),
496        })
497    }
498
499    fn header(&self) -> &TextStateHeader {
500        unsafe { &*self.region.as_ptr().cast::<TextStateHeader>() }
501    }
502
503    fn slots(&self) -> &[TextSlot] {
504        unsafe {
505            std::slice::from_raw_parts(
506                self.region
507                    .as_ptr()
508                    .add(size_of::<TextStateHeader>())
509                    .cast::<TextSlot>(),
510                CELL_TEXT_CAPACITY,
511            )
512        }
513    }
514
515    fn with_structure<T>(
516        &self,
517        operation: impl FnOnce(&[TextSlot], &AtomicU32) -> Result<T>,
518    ) -> Result<T> {
519        let _local = lock_unpoisoned(&self.structural_lock);
520        let _process = self.region.lock_exclusive()?;
521        operation(self.slots(), &self.header().hint)
522    }
523}
524
525#[cfg(unix)]
526fn shm_segment_size() -> usize {
527    size_of::<TextStateHeader>() + CELL_TEXT_CAPACITY * size_of::<TextSlot>()
528}
529
530const _: () = assert!(CELL_TEXT_CAPACITY.is_power_of_two());
531const _: () = assert!(CELL_TEXT_MAX > 0 && CELL_TEXT_MAX <= u16::MAX as usize);
532const _: () = assert!(size_of::<TextStateHeader>() == 64);
533// 16 bytes of fixed fields, then the text, padded to the cache line.
534const _: () = assert!(size_of::<TextSlot>() == (16 + CELL_TEXT_MAX).next_multiple_of(64));