Skip to main content

subetha_cxc/
shared_cell.rs

1//! `SharedCell<T>` - cross-process single-value cell using the
2//! SeqLock protocol over a memory-mapped file.
3//!
4//! `T: Copy` plus a stable `#[repr(C)]` layout is the contract;
5//! readers in different processes will memcpy the same bytes and
6//! interpret them identically. The SeqLock retry loop guarantees
7//! that a reader never observes a torn write across the writer's
8//! payload-update window.
9//!
10//! # SeqLock protocol
11//!
12//! Layout (one cache line):
13//! ```text
14//! +---------+---------+---------+--------------------------+
15//! | magic   | size    | version | payload [u8; PAYLOAD]    |
16//! +---------+---------+---------+--------------------------+
17//!   u32       u32       u32       up to 52 bytes
18//! ```
19//!
20//! Writer protocol:
21//! 1. Bump `version` from V (even) to V+1 (odd). All concurrent
22//!    readers now see an odd version and will retry.
23//! 2. Memcpy the new payload bytes into place.
24//! 3. Bump `version` from V+1 to V+2 (even). Readers resume.
25//!
26//! Reader protocol:
27//! 1. Load `version` (Acquire). If odd, spin and retry.
28//! 2. Memcpy the payload into a local buffer.
29//! 3. Load `version` again (Acquire). If it changed, retry.
30//! 4. Return the buffered payload.
31
32use std::fs::{File, OpenOptions};
33use std::marker::PhantomData;
34use std::mem::{align_of, size_of};
35use std::path::Path;
36use std::sync::atomic::{AtomicU32, Ordering};
37
38use memmap2::{MmapMut, MmapOptions};
39
40pub const CELL_MAGIC: u32 = 0x4350_4D46;
41pub const PAYLOAD_BYTES: usize = 52;
42
43#[repr(C, align(64))]
44pub struct CellHeader {
45    pub magic: u32,
46    pub size: u32,
47    pub version: AtomicU32,
48    pub _pad_to_payload: u32,
49    pub payload: [u8; PAYLOAD_BYTES],
50}
51
52pub const CELL_FILE_SIZE: usize = size_of::<CellHeader>();
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum SharedCellError {
56    LayoutMismatch,
57    PayloadTooLarge,
58    NotInitialised,
59    IoError(std::io::ErrorKind),
60}
61
62impl From<std::io::Error> for SharedCellError {
63    fn from(e: std::io::Error) -> Self { Self::IoError(e.kind()) }
64}
65
66pub struct SharedCell<T: Copy + 'static> {
67    _file: File,
68    mmap: MmapMut,
69    _phantom: PhantomData<T>,
70    header_sidecar: subetha_core::HandshakeHeader,
71    ring_sidecar: Box<subetha_core::ObservationRing>,
72}
73
74unsafe impl<T: Copy + Send + 'static> Send for SharedCell<T> {}
75unsafe impl<T: Copy + Sync + 'static> Sync for SharedCell<T> {}
76
77impl<T: Copy + Send + Sync + 'static> subetha_sidecar::AdaptiveInstance for SharedCell<T> {
78    fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
79    fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
80    fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
81        Box::new(subetha_sidecar::NoMigrationPolicy)
82    }
83}
84
85impl<T: Copy + 'static> SharedCell<T> {
86    /// Obtain the cell at `path`, initializing an empty one if the path
87    /// does not yet exist and attaching to it if it does. Attaching
88    /// leaves the current value and version in place; a region built
89    /// for a different payload type is a `LayoutMismatch`.
90    /// [`reset`](Self::reset) reinitializes.
91    pub fn create(path: impl AsRef<Path>) -> Result<Self, SharedCellError> {
92        Self::check_layout()?;
93        let (file, mmap) = crate::mmf_attach::create_or_attach(
94            path.as_ref(),
95            CELL_FILE_SIZE,
96            |ptr| unsafe { Self::init_region(ptr) },
97            |ptr| unsafe { (*(ptr as *const CellHeader)).magic == CELL_MAGIC },
98        )?;
99        Self::from_region(file, mmap)
100    }
101
102    /// Truncate the cell at `path` and initialize an empty one,
103    /// discarding whatever value a live peer holds. For a caller that
104    /// knows it owns the path.
105    pub fn reset(path: impl AsRef<Path>) -> Result<Self, SharedCellError> {
106        Self::check_layout()?;
107        let (file, mmap) = crate::mmf_attach::reset(path.as_ref(), CELL_FILE_SIZE, |ptr| unsafe {
108            Self::init_region(ptr)
109        })?;
110        Self::from_region(file, mmap)
111    }
112
113    /// Lay out an empty cell: the zeroed region is already version 0
114    /// with a zero payload, so only the size and then the magic are
115    /// written, magic last, because attachers spin on it.
116    ///
117    /// # Safety
118    /// `ptr` addresses at least [`CELL_FILE_SIZE`] writable zeroed
119    /// bytes.
120    unsafe fn init_region(ptr: *mut u8) {
121        let hdr = ptr as *mut CellHeader;
122        unsafe {
123            (*hdr).size = size_of::<T>() as u32;
124            std::ptr::write_volatile(&raw mut (*hdr).magic, CELL_MAGIC);
125        }
126    }
127
128    /// Wrap an initialized region, refusing one built for a different
129    /// payload type.
130    fn from_region(file: File, mmap: MmapMut) -> Result<Self, SharedCellError> {
131        let header = unsafe { &*(mmap.as_ptr() as *const CellHeader) };
132        if header.magic != CELL_MAGIC || header.size as usize != size_of::<T>() {
133            return Err(SharedCellError::LayoutMismatch);
134        }
135        Ok(Self {
136            _file: file, mmap, _phantom: PhantomData,
137            header_sidecar: subetha_core::HandshakeHeader::new(),
138            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
139        })
140    }
141
142    pub fn open(path: impl AsRef<Path>) -> Result<Self, SharedCellError> {
143        Self::check_layout()?;
144        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
145        if file.metadata()?.len() < CELL_FILE_SIZE as u64 {
146            return Err(SharedCellError::LayoutMismatch);
147        }
148        let mmap = unsafe { MmapOptions::new().len(CELL_FILE_SIZE).map_mut(&file)? };
149        Self::from_region(file, mmap)
150    }
151
152    fn check_layout() -> Result<(), SharedCellError> {
153        if size_of::<T>() > PAYLOAD_BYTES {
154            return Err(SharedCellError::PayloadTooLarge);
155        }
156        if align_of::<T>() > 8 {
157            return Err(SharedCellError::PayloadTooLarge);
158        }
159        Ok(())
160    }
161
162    fn header(&self) -> &CellHeader {
163        unsafe { &*(self.mmap.as_ptr() as *const CellHeader) }
164    }
165
166    /// Atomically replace the cell value. SeqLock protocol: odd
167    /// version during the memcpy, even after.
168    pub fn set(&self, value: T) {
169        let header = self.header();
170        // Bump to odd (writer in progress).
171        let v_old = header.version.fetch_add(1, Ordering::AcqRel);
172        debug_assert!(v_old & 1 == 0, "concurrent writers not supported on SharedCell");
173        // SAFETY: payload bytes are exclusive to this writer for
174        // the odd-version window; readers spin until even.
175        unsafe {
176            let dst = header.payload.as_ptr() as *mut T;
177            std::ptr::write_unaligned(dst, value);
178        }
179        // Release fence + bump to even (writer done).
180        header.version.fetch_add(1, Ordering::Release);
181        self.ring_sidecar
182            .push_op(crate::sidecar_ops::cell::OP_SET, 0);
183    }
184
185    /// Read the current value via the SeqLock retry loop. Always
186    /// returns; the loop is bounded by writer frequency.
187    pub fn get(&self) -> T {
188        let header = self.header();
189        let mut retries: u32 = 0;
190        loop {
191            let v1 = header.version.load(Ordering::Acquire);
192            if v1 & 1 != 0 {
193                retries = retries.saturating_add(1);
194                std::hint::spin_loop();
195                continue;
196            }
197            // SAFETY: payload may change under us; the v1 == v2
198            // check below verifies consistency.
199            let value: T = unsafe {
200                let src = header.payload.as_ptr() as *const T;
201                std::ptr::read_unaligned(src)
202            };
203            let v2 = header.version.load(Ordering::Acquire);
204            if v1 == v2 {
205                self.ring_sidecar.push_op(
206                    crate::sidecar_ops::cell::OP_GET,
207                    if retries > 0 { 1 } else { 0 },
208                );
209                return value;
210            }
211            // Writer concurrent with our read; retry.
212            retries = retries.saturating_add(1);
213            std::hint::spin_loop();
214        }
215    }
216
217    pub fn version(&self) -> u32 {
218        self.header().version.load(Ordering::Acquire)
219    }
220
221    /// Non-blocking flush: schedules a writeback via the OS.
222    /// Note: Windows is only partially async (sync to page cache,
223    /// not to disk).
224    pub fn flush_async(&self) -> Result<(), SharedCellError> {
225        self.mmap.flush_async()?;
226        Ok(())
227    }
228
229    pub fn flush(&self) -> Result<(), SharedCellError> {
230        self.mmap.flush()?;
231        Ok(())
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    fn tmp(name: &str) -> std::path::PathBuf {
240        let mut p = std::env::temp_dir();
241        let pid = std::process::id();
242        p.push(format!("subetha-cell-{name}-{pid}.bin"));
243        p
244    }
245
246    #[test]
247    fn round_trip_simple_payload() {
248        let p = tmp("round-trip");
249        let c: SharedCell<u64> = SharedCell::create(&p).unwrap();
250        c.set(42);
251        assert_eq!(c.get(), 42);
252        c.set(99);
253        assert_eq!(c.get(), 99);
254        std::fs::remove_file(&p).ok();
255    }
256
257    /// A second create attaches with the current value in place; reset
258    /// is what strips it.
259    #[test]
260    fn second_create_attaches_and_keeps_the_value() {
261        let p = tmp("attach");
262        std::fs::remove_file(&p).ok();
263        let c: SharedCell<u64> = SharedCell::create(&p).unwrap();
264        c.set(777);
265
266        let c2: SharedCell<u64> = SharedCell::create(&p).unwrap();
267        assert_eq!(c2.get(), 777, "attach clobbered the value");
268
269        // Windows refuses to truncate a mapped file, so every handle goes
270        // before the reset.
271        drop(c);
272        drop(c2);
273        let fresh: SharedCell<u64> = SharedCell::reset(&p).unwrap();
274        assert_eq!(fresh.get(), 0, "reset left a value behind");
275        drop(fresh);
276        std::fs::remove_file(&p).ok();
277    }
278
279    /// Attaching with a different payload type is refused.
280    #[test]
281    fn create_refuses_a_mismatched_region() {
282        let p = tmp("mismatch");
283        std::fs::remove_file(&p).ok();
284        let c: SharedCell<u64> = SharedCell::create(&p).unwrap();
285        assert!(matches!(
286            SharedCell::<u32>::create(&p),
287            Err(SharedCellError::LayoutMismatch),
288        ));
289        drop(c);
290        std::fs::remove_file(&p).ok();
291    }
292
293    #[test]
294    fn cross_handle_visibility() {
295        let p = tmp("cross-handle");
296        let writer: SharedCell<u64> = SharedCell::create(&p).unwrap();
297        let reader: SharedCell<u64> = SharedCell::open(&p).unwrap();
298        writer.set(0xDEAD_BEEF);
299        assert_eq!(reader.get(), 0xDEAD_BEEF);
300        std::fs::remove_file(&p).ok();
301    }
302
303    #[test]
304    fn version_advances_on_each_set() {
305        let p = tmp("version");
306        let c: SharedCell<u32> = SharedCell::create(&p).unwrap();
307        let v0 = c.version();
308        c.set(1);
309        let v1 = c.version();
310        c.set(2);
311        let v2 = c.version();
312        // Each set advances by 2 (odd-then-even).
313        assert_eq!(v1, v0 + 2);
314        assert_eq!(v2, v0 + 4);
315        std::fs::remove_file(&p).ok();
316    }
317
318    #[test]
319    fn disk_persistence_survives_reopen() {
320        let p = tmp("disk-persist");
321        {
322            let c: SharedCell<u64> = SharedCell::create(&p).unwrap();
323            c.set(7777);
324            c.flush().unwrap();
325        }
326        let c2: SharedCell<u64> = SharedCell::open(&p).unwrap();
327        assert_eq!(c2.get(), 7777);
328        std::fs::remove_file(&p).ok();
329    }
330
331    #[test]
332    fn open_rejects_wrong_payload_size() {
333        let p = tmp("wrong-size");
334        let _c: SharedCell<u64> = SharedCell::create(&p).unwrap();
335        match SharedCell::<u32>::open(&p) {
336            Err(SharedCellError::LayoutMismatch) => {}
337            other => panic!("expected LayoutMismatch, got {:?}", other.as_ref().err()),
338        }
339        std::fs::remove_file(&p).ok();
340    }
341
342    #[test]
343    fn struct_payload_round_trip() {
344        #[derive(Clone, Copy, Debug, PartialEq)]
345        #[repr(C)]
346        struct Point { x: f64, y: f64, z: f64 }
347        let p = tmp("struct");
348        let c: SharedCell<Point> = SharedCell::create(&p).unwrap();
349        let pt = Point { x: 1.0, y: 2.0, z: 3.0 };
350        c.set(pt);
351        assert_eq!(c.get(), pt);
352        std::fs::remove_file(&p).ok();
353    }
354
355    #[test]
356    fn concurrent_readers_during_writes() {
357        use std::sync::Arc;
358        use std::thread;
359        let p = tmp("concurrent-rw");
360        let c: Arc<SharedCell<u64>> = Arc::new(SharedCell::create(&p).unwrap());
361        c.set(0);
362        let writer_c = c.clone();
363        let writer = thread::spawn(move || {
364            for i in 1..1000u64 {
365                writer_c.set(i);
366            }
367            999u64
368        });
369        let mut handles = vec![];
370        for _ in 0..4 {
371            let reader_c = c.clone();
372            handles.push(thread::spawn(move || {
373                let mut last = 0u64;
374                for _ in 0..1000 {
375                    let v = reader_c.get();
376                    // Values must be monotonic (writer never goes backwards).
377                    assert!(v >= last, "torn read detected: v={v} last={last}");
378                    last = v;
379                }
380            }));
381        }
382        let final_w = writer.join().unwrap();
383        for h in handles { h.join().unwrap(); }
384        assert!(c.get() >= final_w);
385        std::fs::remove_file(&p).ok();
386    }
387
388    #[test]
389    fn payload_too_large_at_create() {
390        #[allow(dead_code)] // size_of<Big> is the test signal, not the field
391        struct Big([u8; PAYLOAD_BYTES + 1]);
392        impl Copy for Big {}
393        impl Clone for Big { fn clone(&self) -> Self { *self } }
394        let p = tmp("too-large");
395        match SharedCell::<Big>::create(&p) {
396            Err(SharedCellError::PayloadTooLarge) => {}
397            other => panic!("expected PayloadTooLarge, got {:?}", other.as_ref().err()),
398        }
399        std::fs::remove_file(&p).ok();
400    }
401}