Skip to main content

subetha_cxc/
shared_string_arena.rs

1//! `SharedStringArena` - append-only position-independent string
2//! pool backed by an MMF.
3//!
4//! # Why this exists
5//!
6//! Variable-length strings can't be stored inline in fixed-size
7//! slots (SharedHashMap, SharedVec, etc.) without padding waste or
8//! truncation. The natural cross-process solution is a shared byte
9//! arena: every process maps the same file at (potentially)
10//! different base addresses, and string references are
11//! position-independent `(offset, len)` pairs. Adding
12//! `mmap_base + offset` in any process resolves to the same bytes.
13//!
14//! # Layout
15//!
16//! ```text
17//! +---------------------------+
18//! | ArenaHeader (64B)         |
19//! |   magic, capacity_bytes   |
20//! |   used_bytes: AtomicU64   |
21//! +---------------------------+
22//! | bytes[0 .. capacity]      |
23//! +---------------------------+
24//! ```
25//!
26//! # Protocol
27//!
28//! `intern(s)`:
29//! 1. `offset = used_bytes.fetch_add(len)`.
30//! 2. If `offset + len > capacity`, rollback with
31//!    `fetch_sub(len)` and return `Full`. (Note: the
32//!    rollback is best-effort; if two threads race-overflow
33//!    simultaneously, both fetch_subs leave the counter
34//!    deterministic without "losing" bytes.)
35//! 3. Memcpy `s.bytes()` into `arena[offset..offset+len]`.
36//! 4. Return `StringRef { offset, len }`.
37//!
38//! `get(r)`:
39//! - Bounds-check `r.offset + r.len <= used_bytes` (sanity), then
40//!   return `&arena[r.offset..r.offset+r.len]` as a `&str`.
41//!
42//! # Concurrency
43//!
44//! Concurrent interners get distinct slices via fetch_add. Once
45//! the bytes are written, they are never moved (append-only). A
46//! reader holding a StringRef can always resolve it correctly,
47//! provided their `get` happens AFTER the interner returned the
48//! ref (which is the natural happens-before edge: the interner
49//! does the write, then makes the ref visible to the reader).
50//!
51//! # Deduplication
52//!
53//! Not provided here. For dedup, layer a `SharedHashMap<u64 hash,
54//! StringRef>` over the arena and consult it before each intern.
55//!
56//! # No deletion
57//!
58//! Append-only. The whole arena is reclaimed via `clear` (callers
59//! must ensure no concurrent readers); fine-grained deletion
60//! requires a free-list / compaction protocol that defeats the
61//! point of an arena.
62
63use std::fs::{File, OpenOptions};
64use std::mem::size_of;
65use std::path::Path;
66use std::sync::atomic::{AtomicU64, Ordering};
67
68use memmap2::{Mmap, MmapMut, MmapOptions};
69
70/// Format tag written last when a region is initialized, and required to
71/// match on open. The trailing byte is the layout generation: `A1` carried
72/// a `StringRef` packed as offset:u32|len:u32, `A2` packs it 40:24. A
73/// region written by one generation resolves every ref wrongly under the
74/// other, so the tag differs and the older region is refused rather than
75/// misread.
76pub const ARENA_MAGIC: u64 = 0x4150_5341_524E_4132;
77
78/// The `A1` tag, retained so an old-format region is recognised and named
79/// in the refusal instead of reported as unrecognised bytes.
80pub const ARENA_MAGIC_V1: u64 = 0x4150_5341_524E_4131;
81
82/// Bits of a packed [`StringRef`] given to the byte offset, and the
83/// resulting ceiling on arena capacity.
84pub const OFFSET_BITS: u32 = 40;
85/// Bits given to the string length, and the resulting ceiling on one
86/// interned string.
87pub const LEN_BITS: u32 = 24;
88
89const _: () = assert!(OFFSET_BITS + LEN_BITS == 64);
90
91/// Largest addressable byte offset: 1 TiB - 1.
92pub const MAX_OFFSET: u64 = (1u64 << OFFSET_BITS) - 1;
93/// Largest interned string: 16 MiB - 1.
94pub const MAX_LEN: u64 = (1u64 << LEN_BITS) - 1;
95
96/// How this process mapped the file. `MmapMut` demands a read+write
97/// file handle, which a consumer holding read access alone cannot get.
98enum Mapping {
99    Writable(MmapMut),
100    ReadOnly(Mmap),
101}
102
103impl Mapping {
104    #[inline]
105    fn as_ptr(&self) -> *const u8 {
106        match self {
107            Mapping::Writable(m) => m.as_ptr(),
108            Mapping::ReadOnly(m) => m.as_ptr(),
109        }
110    }
111
112    #[inline]
113    fn is_writable(&self) -> bool {
114        matches!(self, Mapping::Writable(_))
115    }
116
117    fn flush(&self) -> Result<(), std::io::Error> {
118        match self {
119            Mapping::Writable(m) => m.flush(),
120            Mapping::ReadOnly(_) => Ok(()),
121        }
122    }
123
124    fn flush_async(&self) -> Result<(), std::io::Error> {
125        match self {
126            Mapping::Writable(m) => m.flush_async(),
127            Mapping::ReadOnly(_) => Ok(()),
128        }
129    }
130}
131
132#[repr(C, align(64))]
133pub struct ArenaHeader {
134    pub magic: u64,
135    pub capacity_bytes: u64,
136    pub used_bytes: AtomicU64,
137    _pad: [u8; 40],
138}
139
140const _: () = {
141    assert!(size_of::<ArenaHeader>() == 64);
142};
143
144pub const fn arena_file_size(capacity_bytes: usize) -> usize {
145    size_of::<ArenaHeader>() + capacity_bytes
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum ArenaError {
150    Full,
151    InvalidRef,
152    InvalidUtf8,
153    LayoutMismatch,
154    /// The arena was opened read-only and something tried to write it.
155    ReadOnly,
156    IoError(std::io::ErrorKind),
157}
158
159impl From<std::io::Error> for ArenaError {
160    fn from(e: std::io::Error) -> Self { Self::IoError(e.kind()) }
161}
162
163/// Position-independent reference to a string in a SharedStringArena.
164/// Encoded as a `u64` of [`OFFSET_BITS`] offset and [`LEN_BITS`] length,
165/// for stable cross-process passing: the same u64 resolves to the same
166/// bytes in every process that maps the arena.
167///
168/// The split gives a 1 TiB arena holding strings of up to 16 MiB each.
169/// Both are enforced where a ref is minted, so a value that does not fit
170/// is refused rather than truncated into a ref that reads someone else's
171/// bytes.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
173pub struct StringRef {
174    pub offset: u64,
175    pub len: u32,
176}
177
178impl StringRef {
179    #[inline]
180    pub fn to_u64(self) -> u64 {
181        ((self.offset & MAX_OFFSET) << LEN_BITS) | (self.len as u64 & MAX_LEN)
182    }
183    #[inline]
184    pub fn from_u64(v: u64) -> Self {
185        Self {
186            offset: v >> LEN_BITS,
187            len: (v & MAX_LEN) as u32,
188        }
189    }
190}
191
192pub struct SharedStringArena {
193    _file: File,
194    mmap: Mapping,
195    capacity_bytes: usize,
196    header_sidecar: subetha_core::HandshakeHeader,
197    ring_sidecar: Box<subetha_core::ObservationRing>,
198}
199
200unsafe impl Send for SharedStringArena {}
201unsafe impl Sync for SharedStringArena {}
202
203impl subetha_sidecar::AdaptiveInstance for SharedStringArena {
204    fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
205    fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
206    fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
207        Box::new(subetha_sidecar::NoMigrationPolicy)
208    }
209}
210
211impl SharedStringArena {
212    /// Obtain the arena at `path`, initializing an empty one if the
213    /// path does not yet exist and attaching to it if it does.
214    /// Attaching leaves interned strings and `used_bytes` in place, so
215    /// outstanding [`StringRef`]s stay resolvable; a region built with
216    /// a different capacity is a `LayoutMismatch`.
217    /// [`reset`](Self::reset) reinitializes.
218    pub fn create(
219        path: impl AsRef<Path>, capacity_bytes: usize,
220    ) -> Result<Self, ArenaError> {
221        Self::check_capacity(capacity_bytes)?;
222        // An existing region carrying an older layout tag never satisfies
223        // the readiness test, so attaching would spin to its deadline and
224        // report that the creator never finished - which is not what
225        // happened. Name the layout instead, before any of that.
226        if Self::region_format_tag(path.as_ref()) == Some(ARENA_MAGIC_V1) {
227            return Err(ArenaError::LayoutMismatch);
228        }
229        let (file, mmap) = crate::mmf_attach::create_or_attach(
230            path.as_ref(),
231            arena_file_size(capacity_bytes),
232            |ptr| unsafe { Self::init_region(ptr, capacity_bytes) },
233            |ptr| unsafe { (*(ptr as *const ArenaHeader)).magic == ARENA_MAGIC },
234        )?;
235        let this = Self {
236            _file: file, mmap: Mapping::Writable(mmap), capacity_bytes,
237            header_sidecar: subetha_core::HandshakeHeader::new(),
238            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
239        };
240        this.validate(capacity_bytes)?;
241        Ok(this)
242    }
243
244    /// Truncate the arena at `path` and initialize an empty one,
245    /// invalidating every StringRef live peers hold. For a caller that
246    /// knows it owns the path.
247    pub fn reset(
248        path: impl AsRef<Path>, capacity_bytes: usize,
249    ) -> Result<Self, ArenaError> {
250        Self::check_capacity(capacity_bytes)?;
251        let (file, mmap) = crate::mmf_attach::reset(
252            path.as_ref(),
253            arena_file_size(capacity_bytes),
254            |ptr| unsafe { Self::init_region(ptr, capacity_bytes) },
255        )?;
256        Ok(Self {
257            _file: file, mmap: Mapping::Writable(mmap), capacity_bytes,
258            header_sidecar: subetha_core::HandshakeHeader::new(),
259            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
260        })
261    }
262
263    /// The format tag an existing region carries, or `None` when the path
264    /// does not exist or is too short to hold one. Reads the file rather
265    /// than mapping it, so it says nothing about whether the region is
266    /// otherwise usable.
267    fn region_format_tag(path: &Path) -> Option<u64> {
268        use std::io::Read;
269        let mut f = File::open(path).ok()?;
270        let mut tag = [0u8; 8];
271        f.read_exact(&mut tag).ok()?;
272        Some(u64::from_le_bytes(tag))
273    }
274
275    /// A capacity every constructor must agree on: at least one byte,
276    /// and no larger than a [`StringRef`] offset can address. Reported
277    /// rather than asserted, because a service that panics building its
278    /// arena dies with it.
279    fn check_capacity(capacity_bytes: usize) -> Result<(), ArenaError> {
280        if capacity_bytes < 1 || capacity_bytes as u64 > MAX_OFFSET {
281            return Err(ArenaError::LayoutMismatch);
282        }
283        Ok(())
284    }
285
286    /// Lay out an empty arena: capacity first, magic last, because
287    /// attachers spin on it. The zeroed region is already `used_bytes`
288    /// 0 and empty byte space.
289    ///
290    /// # Safety
291    /// `ptr` addresses at least `arena_file_size(capacity_bytes)`
292    /// writable zeroed bytes.
293    unsafe fn init_region(ptr: *mut u8, capacity_bytes: usize) {
294        let hdr = ptr as *mut ArenaHeader;
295        unsafe {
296            (*hdr).capacity_bytes = capacity_bytes as u64;
297            std::ptr::write_volatile(&raw mut (*hdr).magic, ARENA_MAGIC);
298        }
299    }
300
301    pub fn open(
302        path: impl AsRef<Path>, expected_capacity_bytes: usize,
303    ) -> Result<Self, ArenaError> {
304        // A capacity a StringRef offset cannot address is not a layout
305        // this crate ever creates, and interning into it would truncate.
306        if expected_capacity_bytes as u64 > MAX_OFFSET {
307            return Err(ArenaError::LayoutMismatch);
308        }
309        let total = arena_file_size(expected_capacity_bytes);
310        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
311        if file.metadata()?.len() < total as u64 {
312            return Err(ArenaError::LayoutMismatch);
313        }
314        let mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
315        let this = Self {
316            _file: file, mmap: Mapping::Writable(mmap),
317            capacity_bytes: expected_capacity_bytes,
318            header_sidecar: subetha_core::HandshakeHeader::new(),
319            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
320        };
321        this.validate(expected_capacity_bytes)?;
322        Ok(this)
323    }
324
325    /// Open an arena this process may only read.
326    ///
327    /// [`open`](Self::open) needs a read+write file handle, which a
328    /// consumer of a privileged producer's arena does not have. Reads
329    /// behave identically; [`intern`](Self::intern) and friends return
330    /// [`ArenaError::ReadOnly`].
331    pub fn open_read_only(
332        path: impl AsRef<Path>, expected_capacity_bytes: usize,
333    ) -> Result<Self, ArenaError> {
334        let total = arena_file_size(expected_capacity_bytes);
335        let file = OpenOptions::new().read(true).open(path.as_ref())?;
336        if file.metadata()?.len() < total as u64 {
337            return Err(ArenaError::LayoutMismatch);
338        }
339        let mmap = unsafe { MmapOptions::new().len(total).map(&file)? };
340        let this = Self {
341            _file: file, mmap: Mapping::ReadOnly(mmap),
342            capacity_bytes: expected_capacity_bytes,
343            header_sidecar: subetha_core::HandshakeHeader::new(),
344            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
345        };
346        this.validate(expected_capacity_bytes)?;
347        Ok(this)
348    }
349
350    /// Whether the header on disk is the one this mapping expects.
351    fn validate(&self, expected_capacity_bytes: usize) -> Result<(), ArenaError> {
352        let hdr = self.header();
353        if hdr.magic != ARENA_MAGIC || hdr.capacity_bytes != expected_capacity_bytes as u64 {
354            return Err(ArenaError::LayoutMismatch);
355        }
356        Ok(())
357    }
358
359    /// Whether this mapping may be written.
360    #[inline]
361    pub fn is_writable(&self) -> bool {
362        self.mmap.is_writable()
363    }
364
365    #[inline]
366    pub fn capacity_bytes(&self) -> usize { self.capacity_bytes }
367
368    #[inline]
369    pub fn used_bytes(&self) -> usize {
370        self.header().used_bytes.load(Ordering::Acquire) as usize
371    }
372
373    #[inline]
374    pub fn remaining_bytes(&self) -> usize {
375        self.capacity_bytes.saturating_sub(self.used_bytes())
376    }
377
378    fn header(&self) -> &ArenaHeader {
379        unsafe { &*(self.mmap.as_ptr() as *const ArenaHeader) }
380    }
381
382    /// Append a string to the arena. Returns a StringRef that
383    /// resolves to the bytes in any mapping of the same file.
384    ///
385    /// Returns `Err(Full)` when the arena has no room. The empty
386    /// string `""` interns at the current offset with `len = 0`.
387    pub fn intern(&self, s: &str) -> Result<StringRef, ArenaError> {
388        self.intern_bytes(s.as_bytes())
389    }
390
391    /// Append arbitrary bytes (not necessarily UTF-8) to the arena.
392    /// Useful for storing binary blobs alongside strings. Retrieve
393    /// with `get_bytes`; `get` will reject non-UTF-8 with
394    /// `InvalidUtf8`.
395    pub fn intern_bytes(&self, bytes: &[u8]) -> Result<StringRef, ArenaError> {
396        if !self.mmap.is_writable() {
397            return Err(ArenaError::ReadOnly);
398        }
399        let len = bytes.len() as u64;
400        // A StringRef carries the length in LEN_BITS, so a longer string is
401        // refused here rather than wrapped into a ref that resolves to a
402        // prefix of itself and silently loses the tail.
403        if len > MAX_LEN || len > self.capacity_bytes as u64 {
404            self.ring_sidecar
405                .push_op(crate::sidecar_ops::string_arena::OP_INTERN, 1);
406            return Err(ArenaError::Full);
407        }
408        let offset = self.header().used_bytes.fetch_add(len, Ordering::AcqRel);
409        if offset.saturating_add(len) > self.capacity_bytes as u64 {
410            self.header().used_bytes.fetch_sub(len, Ordering::AcqRel);
411            self.ring_sidecar
412                .push_op(crate::sidecar_ops::string_arena::OP_INTERN, 1);
413            return Err(ArenaError::Full);
414        }
415        // A StringRef carries the offset in OFFSET_BITS, so an offset past
416        // that is refused here rather than truncated: a truncated offset
417        // lands inside the used region and resolves to another string's
418        // bytes, which every downstream bounds check would accept.
419        // `create` and `reset` refuse such a capacity, so this covers an
420        // arena opened at a capacity they never sanctioned.
421        if offset.saturating_add(len) > MAX_OFFSET {
422            self.header().used_bytes.fetch_sub(len, Ordering::AcqRel);
423            self.ring_sidecar
424                .push_op(crate::sidecar_ops::string_arena::OP_INTERN, 1);
425            return Err(ArenaError::Full);
426        }
427        let dst = unsafe {
428            self.mmap.as_ptr()
429                .add(size_of::<ArenaHeader>())
430                .add(offset as usize)
431                as *mut u8
432        };
433        unsafe {
434            std::ptr::copy_nonoverlapping(bytes.as_ptr(), dst, bytes.len());
435        }
436        self.ring_sidecar
437            .push_op(crate::sidecar_ops::string_arena::OP_INTERN, 0);
438        Ok(StringRef { offset, len: len as u32 })
439    }
440
441    /// Resolve a StringRef to its `&[u8]`. Returns `Err(InvalidRef)`
442    /// when the ref doesn't fall inside the arena's used region.
443    pub fn get_bytes(&self, r: StringRef) -> Result<&[u8], ArenaError> {
444        let end = r.offset.saturating_add(r.len as u64);
445        if end > self.header().used_bytes.load(Ordering::Acquire) {
446            self.ring_sidecar
447                .push_op(crate::sidecar_ops::string_arena::OP_GET_BYTES, 1);
448            return Err(ArenaError::InvalidRef);
449        }
450        if end > self.capacity_bytes as u64 {
451            self.ring_sidecar
452                .push_op(crate::sidecar_ops::string_arena::OP_GET_BYTES, 1);
453            return Err(ArenaError::InvalidRef);
454        }
455        self.ring_sidecar
456            .push_op(crate::sidecar_ops::string_arena::OP_GET_BYTES, 0);
457        let base = unsafe {
458            self.mmap.as_ptr()
459                .add(size_of::<ArenaHeader>())
460                .add(r.offset as usize)
461        };
462        Ok(unsafe { std::slice::from_raw_parts(base, r.len as usize) })
463    }
464
465    /// Resolve a StringRef to a `&str`. Returns `Err(InvalidUtf8)`
466    /// when the bytes aren't valid UTF-8 (the arena doesn't enforce
467    /// validity per-segment; it's checked on read).
468    pub fn get(&self, r: StringRef) -> Result<&str, ArenaError> {
469        let bytes = self.get_bytes(r)?;
470        std::str::from_utf8(bytes).map_err(|_| ArenaError::InvalidUtf8)
471    }
472
473    /// Convenience: intern AND return a `&str` view into the
474    /// just-written bytes plus the ref.
475    pub fn intern_and_get(&self, s: &str) -> Result<(StringRef, &str), ArenaError> {
476        let r = self.intern(s)?;
477        let got = self.get(r)?;
478        Ok((r, got))
479    }
480
481    /// Reset the arena to empty. NOT concurrency-safe; callers must
482    /// ensure no other threads/processes are interning or reading.
483    /// Existing StringRefs become invalid (their bytes may be
484    /// overwritten by subsequent interns).
485    pub fn clear(&self) {
486        if !self.mmap.is_writable() {
487            return;
488        }
489        self.header().used_bytes.store(0, Ordering::Release);
490        self.ring_sidecar
491            .push_op(crate::sidecar_ops::string_arena::OP_CLEAR, 0);
492    }
493
494    pub fn flush(&self) -> Result<(), ArenaError> {
495        self.mmap.flush()?;
496        Ok(())
497    }
498
499    /// Non-blocking flush: schedules a writeback via the OS.
500    /// Note: Windows is only partially async (sync to page cache,
501    /// not to disk).
502    pub fn flush_async(&self) -> Result<(), ArenaError> {
503        self.mmap.flush_async()?;
504        Ok(())
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use std::sync::Arc;
512    use std::thread;
513
514    fn tmp(name: &str) -> std::path::PathBuf {
515        let mut p = std::env::temp_dir();
516        let pid = std::process::id();
517        p.push(format!("subetha-arena-{name}-{pid}.bin"));
518        p
519    }
520
521    /// A second create attaches with interned strings in place; reset
522    /// is what strips them.
523    #[test]
524    fn second_create_attaches_and_keeps_strings() {
525        let p = tmp("attach");
526        std::fs::remove_file(&p).ok();
527        let a = SharedStringArena::create(&p, 4096).unwrap();
528        let r = a.intern("held").unwrap();
529
530        let a2 = SharedStringArena::create(&p, 4096).unwrap();
531        assert_eq!(a2.get(r).unwrap(), "held", "attach lost an interned string");
532        assert!(matches!(
533            SharedStringArena::create(&p, 2048),
534            Err(ArenaError::LayoutMismatch),
535        ));
536
537        // Windows refuses to truncate a mapped file, so every handle goes
538        // before the reset.
539        drop(a);
540        drop(a2);
541        let fresh = SharedStringArena::reset(&p, 4096).unwrap();
542        assert_eq!(fresh.used_bytes(), 0, "reset kept interned bytes");
543        drop(fresh);
544        std::fs::remove_file(&p).ok();
545    }
546
547    #[test]
548    fn a_read_only_arena_resolves_refs_and_refuses_interning() {
549        let p = tmp("readonly");
550        let r = {
551            let w = SharedStringArena::create(&p, 4096).unwrap();
552            let r = w.intern("notepad.exe").unwrap();
553            w.flush().unwrap();
554            r
555        };
556        let ro = SharedStringArena::open_read_only(&p, 4096).unwrap();
557        assert!(!ro.is_writable());
558        assert_eq!(ro.get(r), Ok("notepad.exe"));
559        assert_eq!(ro.get_bytes(r), Ok(&b"notepad.exe"[..]));
560        assert_eq!(ro.used_bytes(), 11);
561        assert_eq!(ro.intern("more"), Err(ArenaError::ReadOnly));
562        assert_eq!(ro.intern_bytes(b"more"), Err(ArenaError::ReadOnly));
563        ro.clear();
564        assert_eq!(ro.used_bytes(), 11, "clear on a read-only arena is inert");
565        ro.flush().unwrap();
566        std::fs::remove_file(&p).ok();
567    }
568
569    #[test]
570    fn a_read_only_open_still_validates_the_header() {
571        let p = tmp("readonly-mismatch");
572        {
573            let w = SharedStringArena::create(&p, 4096).unwrap();
574            w.intern("x").unwrap();
575            w.flush().unwrap();
576        }
577        assert_eq!(
578            SharedStringArena::open_read_only(&p, 2048).err(),
579            Some(ArenaError::LayoutMismatch)
580        );
581        std::fs::remove_file(&p).ok();
582    }
583
584    #[test]
585    fn create_initial_state_is_empty() {
586        let p = tmp("init");
587        let a = SharedStringArena::create(&p, 1024).unwrap();
588        assert_eq!(a.capacity_bytes(), 1024);
589        assert_eq!(a.used_bytes(), 0);
590        assert_eq!(a.remaining_bytes(), 1024);
591        std::fs::remove_file(&p).ok();
592    }
593
594    #[test]
595    fn intern_and_get_round_trip() {
596        let p = tmp("rt");
597        let a = SharedStringArena::create(&p, 1024).unwrap();
598        let r1 = a.intern("hello").unwrap();
599        let r2 = a.intern("world").unwrap();
600        assert_eq!(a.get(r1).unwrap(), "hello");
601        assert_eq!(a.get(r2).unwrap(), "world");
602        assert_eq!(a.used_bytes(), 10);
603        std::fs::remove_file(&p).ok();
604    }
605
606    #[test]
607    fn empty_string_interns_with_zero_len() {
608        let p = tmp("empty");
609        let a = SharedStringArena::create(&p, 16).unwrap();
610        let r = a.intern("").unwrap();
611        assert_eq!(r.len, 0);
612        assert_eq!(a.get(r).unwrap(), "");
613        assert_eq!(a.used_bytes(), 0);
614        std::fs::remove_file(&p).ok();
615    }
616
617    #[test]
618    fn full_arena_returns_error() {
619        let p = tmp("full");
620        let a = SharedStringArena::create(&p, 10).unwrap();
621        a.intern("hello").unwrap();
622        a.intern("world").unwrap();
623        assert_eq!(a.intern("more").err(), Some(ArenaError::Full));
624        // Used bytes rolled back, not 14.
625        assert_eq!(a.used_bytes(), 10);
626        std::fs::remove_file(&p).ok();
627    }
628
629    #[test]
630    fn string_too_large_returns_full() {
631        let p = tmp("too-large");
632        let a = SharedStringArena::create(&p, 8).unwrap();
633        let big = "x".repeat(100);
634        assert_eq!(a.intern(&big).err(), Some(ArenaError::Full));
635        assert_eq!(a.used_bytes(), 0);
636        std::fs::remove_file(&p).ok();
637    }
638
639    #[test]
640    fn string_ref_packs_and_unpacks() {
641        let r = StringRef { offset: 0x1234_5678, len: 42 };
642        let packed = r.to_u64();
643        let unpacked = StringRef::from_u64(packed);
644        assert_eq!(unpacked, r);
645    }
646
647    #[test]
648    fn cross_handle_visibility() {
649        let p = tmp("cross-handle");
650        let writer = SharedStringArena::create(&p, 1024).unwrap();
651        let reader = SharedStringArena::open(&p, 1024).unwrap();
652        let r = writer.intern("cross-process").unwrap();
653        assert_eq!(reader.get(r).unwrap(), "cross-process");
654        std::fs::remove_file(&p).ok();
655    }
656
657    #[test]
658    fn invalid_ref_beyond_used_rejected() {
659        let p = tmp("invalid");
660        let a = SharedStringArena::create(&p, 1024).unwrap();
661        a.intern("hi").unwrap();  // used = 2
662        let bad = StringRef { offset: 100, len: 5 };
663        assert_eq!(a.get(bad).err(), Some(ArenaError::InvalidRef));
664        std::fs::remove_file(&p).ok();
665    }
666
667    #[test]
668    fn concurrent_interners_get_distinct_refs() {
669        let p = tmp("concurrent");
670        let a: Arc<SharedStringArena> = Arc::new(SharedStringArena::create(&p, 4096).unwrap());
671        let n_threads = 4;
672        let per_thread = 20;
673        let mut handles = vec![];
674        for t in 0..n_threads {
675            let a = a.clone();
676            handles.push(thread::spawn(move || {
677                let mut refs = vec![];
678                for i in 0..per_thread {
679                    let s = format!("thread-{t}-msg-{i:03}");
680                    let r = a.intern(&s).unwrap();
681                    refs.push((s, r));
682                }
683                refs
684            }));
685        }
686        let all: Vec<(String, StringRef)> = handles.into_iter()
687            .flat_map(|h| h.join().unwrap())
688            .collect();
689        // Every interned string must read back to its original value.
690        for (expected, r) in &all {
691            let got = a.get(*r).unwrap();
692            assert_eq!(got, expected,
693                "ref offset={} len={} should resolve to {expected}",
694                r.offset, r.len);
695        }
696        // No two refs overlap.
697        let mut refs: Vec<StringRef> = all.iter().map(|(_, r)| *r).collect();
698        refs.sort_by_key(|r| r.offset);
699        for w in refs.windows(2) {
700            let r1_end = w[0].offset + w[0].len as u64;
701            assert!(r1_end <= w[1].offset,
702                "ref {:?} overlaps with ref {:?}", w[0], w[1]);
703        }
704        std::fs::remove_file(&p).ok();
705    }
706
707    #[test]
708    fn intern_and_get_helper_returns_both() {
709        let p = tmp("intern-and-get");
710        let a = SharedStringArena::create(&p, 1024).unwrap();
711        let (r, s) = a.intern_and_get("composite").unwrap();
712        assert_eq!(s, "composite");
713        assert_eq!(a.get(r).unwrap(), "composite");
714        std::fs::remove_file(&p).ok();
715    }
716
717    #[test]
718    fn utf8_validation_on_get() {
719        let p = tmp("utf8");
720        let a = SharedStringArena::create(&p, 128).unwrap();
721        // Intern valid UTF-8.
722        let r = a.intern("hello").unwrap();
723        assert!(a.get(r).is_ok());
724        // intern_bytes accepts arbitrary bytes; get() then rejects
725        // non-UTF-8 with InvalidUtf8 while get_bytes returns the raw
726        // bytes without validation.
727        let r2 = a.intern_bytes(&[0xFF, 0xFE, 0xFD]).unwrap();
728        assert_eq!(a.get(r2).err(), Some(ArenaError::InvalidUtf8));
729        assert_eq!(a.get_bytes(r2).unwrap(), &[0xFF, 0xFE, 0xFD]);
730        std::fs::remove_file(&p).ok();
731    }
732
733    #[test]
734    fn clear_resets_used_bytes() {
735        let p = tmp("clear");
736        let a = SharedStringArena::create(&p, 128).unwrap();
737        a.intern("first").unwrap();
738        a.intern("second").unwrap();
739        assert!(a.used_bytes() > 0);
740        a.clear();
741        assert_eq!(a.used_bytes(), 0);
742        // Fresh interns work.
743        let r = a.intern("after-clear").unwrap();
744        assert_eq!(a.get(r).unwrap(), "after-clear");
745        assert_eq!(r.offset, 0);
746        std::fs::remove_file(&p).ok();
747    }
748
749    #[test]
750    fn disk_persistence_survives_reopen() {
751        let p = tmp("disk");
752        let r_persist;
753        {
754            let a = SharedStringArena::create(&p, 1024).unwrap();
755            r_persist = a.intern("persisted-string").unwrap();
756            a.flush().unwrap();
757        }
758        let a2 = SharedStringArena::open(&p, 1024).unwrap();
759        assert_eq!(a2.get(r_persist).unwrap(), "persisted-string");
760        // And it can keep interning.
761        let r2 = a2.intern("more-after-reopen").unwrap();
762        assert_eq!(a2.get(r2).unwrap(), "more-after-reopen");
763        std::fs::remove_file(&p).ok();
764    }
765
766    /// A StringRef addresses its bytes with an OFFSET_BITS offset, so a
767    /// capacity past that is refused at construction rather than silently
768    /// truncating every ref past the ceiling into another string's bytes.
769    /// No file is touched: the check precedes the mapping.
770    #[test]
771    fn create_refuses_a_capacity_a_ref_cannot_address() {
772        let past = MAX_OFFSET as usize + 1;
773        assert!(matches!(
774            SharedStringArena::create(tmp("too-big"), past),
775            Err(ArenaError::LayoutMismatch),
776        ));
777        assert!(matches!(
778            SharedStringArena::reset(tmp("too-big-reset"), past),
779            Err(ArenaError::LayoutMismatch),
780        ));
781        assert!(matches!(
782            SharedStringArena::create(tmp("zero-cap"), 0),
783            Err(ArenaError::LayoutMismatch),
784        ));
785    }
786
787    /// The same layout refused on the open path, where no assertion
788    /// guards construction.
789    #[test]
790    fn open_refuses_a_capacity_a_ref_cannot_address() {
791        assert!(matches!(
792            SharedStringArena::open(tmp("open-too-big"), MAX_OFFSET as usize + 1),
793            Err(ArenaError::LayoutMismatch),
794        ));
795    }
796
797    /// The packing is what every process agrees on, so it has to round-trip
798    /// exactly at the extremes of both fields - the places a shift or mask
799    /// off by one bit shows up and nowhere else.
800    #[test]
801    fn a_string_ref_round_trips_at_both_field_ceilings() {
802        for (offset, len) in [
803            (0u64, 0u32),
804            (MAX_OFFSET, MAX_LEN as u32),
805            (MAX_OFFSET, 0),
806            (0, MAX_LEN as u32),
807            (1, 1),
808            (MAX_OFFSET - 1, MAX_LEN as u32 - 1),
809        ] {
810            let r = StringRef { offset, len };
811            let back = StringRef::from_u64(r.to_u64());
812            assert_eq!(back, r, "offset {offset} len {len} did not round-trip");
813        }
814        // The two fields must not bleed into each other: a maximal length
815        // leaves the offset untouched and the reverse.
816        assert_eq!(StringRef { offset: 0, len: MAX_LEN as u32 }.to_u64(), MAX_LEN);
817        assert_eq!(
818            StringRef { offset: MAX_OFFSET, len: 0 }.to_u64(),
819            MAX_OFFSET << LEN_BITS
820        );
821    }
822
823    /// A region written under the previous layout resolves every ref
824    /// wrongly under this one, so opening it must be refused rather than
825    /// read as though the bits meant the same thing. The fixture is a real
826    /// arena file with only its format tag rewritten, so what is refused is
827    /// the layout and not some other corruption.
828    #[test]
829    fn an_old_format_region_is_refused() {
830        let p = tmp("old-format");
831        std::fs::remove_file(&p).ok();
832        {
833            let a = SharedStringArena::create(&p, 1024).unwrap();
834            a.intern("written-under-the-new-layout").unwrap();
835            a.flush().unwrap();
836        }
837        // Stamp the previous generation's tag over the header's magic.
838        {
839            use std::io::{Seek, SeekFrom, Write};
840            let mut f = OpenOptions::new().write(true).open(&p).unwrap();
841            f.seek(SeekFrom::Start(0)).unwrap();
842            f.write_all(&ARENA_MAGIC_V1.to_le_bytes()).unwrap();
843            f.flush().unwrap();
844        }
845        assert!(
846            matches!(
847                SharedStringArena::open(&p, 1024),
848                Err(ArenaError::LayoutMismatch)
849            ),
850            "an arena tagged with the previous layout must be refused"
851        );
852        // The obtain-on-create path must name the layout too, rather than
853        // spinning to its deadline and blaming an absent creator.
854        let started = std::time::Instant::now();
855        assert!(
856            matches!(
857                SharedStringArena::create(&p, 1024),
858                Err(ArenaError::LayoutMismatch)
859            ),
860            "create must refuse the previous layout, not attach to it"
861        );
862        assert!(
863            started.elapsed() < std::time::Duration::from_secs(1),
864            "create should refuse immediately, not wait out the attach deadline"
865        );
866        std::fs::remove_file(&p).ok();
867    }
868
869    #[test]
870    fn deduplication_via_hashmap_composition() {
871        // Demonstrate the dedup pattern: layer SharedHashMap<u64, u64>
872        // (hash -> StringRef.to_u64) over the arena.
873        use crate::SharedHashMap;
874        use crate::shared_hash_map::fnv1a_64;
875
876        let p_arena = tmp("dedup-arena");
877        let p_index = tmp("dedup-index");
878        let arena = SharedStringArena::create(&p_arena, 256).unwrap();
879        let index: SharedHashMap<u64, u64> = SharedHashMap::create(&p_index, 32).unwrap();
880
881        let s = "deduplicate-me";
882        let h = fnv1a_64(s.as_bytes());
883
884        // First intern: check index, miss, intern + insert into index.
885        let r = if let Some(packed) = index.get(&h) {
886            StringRef::from_u64(packed)
887        } else {
888            let r = arena.intern(s).unwrap();
889            index.insert(h, r.to_u64()).unwrap();
890            r
891        };
892        let used_after_first = arena.used_bytes();
893
894        // Second intern of same string: hit in index, no arena append.
895        let r2 = if let Some(packed) = index.get(&h) {
896            StringRef::from_u64(packed)
897        } else {
898            let r = arena.intern(s).unwrap();
899            index.insert(h, r.to_u64()).unwrap();
900            r
901        };
902        assert_eq!(r, r2, "dedup should return the same ref");
903        assert_eq!(arena.used_bytes(), used_after_first,
904            "second intern should not consume more bytes");
905
906        std::fs::remove_file(&p_arena).ok();
907        std::fs::remove_file(&p_index).ok();
908    }
909}