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    pub fn create(path: impl AsRef<Path>) -> Result<Self, SharedCellError> {
87        Self::check_layout()?;
88        let file = OpenOptions::new()
89            .read(true).write(true).create(true).truncate(true)
90            .open(path.as_ref())?;
91        file.set_len(CELL_FILE_SIZE as u64)?;
92        let mut mmap = unsafe { MmapOptions::new().len(CELL_FILE_SIZE).map_mut(&file)? };
93        let ptr = mmap.as_mut_ptr() as *mut CellHeader;
94        unsafe {
95            std::ptr::write(ptr, CellHeader {
96                magic: CELL_MAGIC,
97                size: size_of::<T>() as u32,
98                version: AtomicU32::new(0),
99                _pad_to_payload: 0,
100                payload: [0; PAYLOAD_BYTES],
101            });
102        }
103        Ok(Self {
104            _file: file, mmap, _phantom: PhantomData,
105            header_sidecar: subetha_core::HandshakeHeader::new(),
106            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
107        })
108    }
109
110    pub fn open(path: impl AsRef<Path>) -> Result<Self, SharedCellError> {
111        Self::check_layout()?;
112        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
113        if file.metadata()?.len() < CELL_FILE_SIZE as u64 {
114            return Err(SharedCellError::LayoutMismatch);
115        }
116        let mmap = unsafe { MmapOptions::new().len(CELL_FILE_SIZE).map_mut(&file)? };
117        let header = unsafe { &*(mmap.as_ptr() as *const CellHeader) };
118        if header.magic != CELL_MAGIC || header.size as usize != size_of::<T>() {
119            return Err(SharedCellError::LayoutMismatch);
120        }
121        Ok(Self {
122            _file: file, mmap, _phantom: PhantomData,
123            header_sidecar: subetha_core::HandshakeHeader::new(),
124            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
125        })
126    }
127
128    fn check_layout() -> Result<(), SharedCellError> {
129        if size_of::<T>() > PAYLOAD_BYTES {
130            return Err(SharedCellError::PayloadTooLarge);
131        }
132        if align_of::<T>() > 8 {
133            return Err(SharedCellError::PayloadTooLarge);
134        }
135        Ok(())
136    }
137
138    fn header(&self) -> &CellHeader {
139        unsafe { &*(self.mmap.as_ptr() as *const CellHeader) }
140    }
141
142    /// Atomically replace the cell value. SeqLock protocol: odd
143    /// version during the memcpy, even after.
144    pub fn set(&self, value: T) {
145        let header = self.header();
146        // Bump to odd (writer in progress).
147        let v_old = header.version.fetch_add(1, Ordering::AcqRel);
148        debug_assert!(v_old & 1 == 0, "concurrent writers not supported on SharedCell");
149        // SAFETY: payload bytes are exclusive to this writer for
150        // the odd-version window; readers spin until even.
151        unsafe {
152            let dst = header.payload.as_ptr() as *mut T;
153            std::ptr::write_unaligned(dst, value);
154        }
155        // Release fence + bump to even (writer done).
156        header.version.fetch_add(1, Ordering::Release);
157        self.ring_sidecar
158            .push_op(crate::sidecar_ops::cell::OP_SET, 0);
159    }
160
161    /// Read the current value via the SeqLock retry loop. Always
162    /// returns; the loop is bounded by writer frequency.
163    pub fn get(&self) -> T {
164        let header = self.header();
165        let mut retries: u32 = 0;
166        loop {
167            let v1 = header.version.load(Ordering::Acquire);
168            if v1 & 1 != 0 {
169                retries = retries.saturating_add(1);
170                std::hint::spin_loop();
171                continue;
172            }
173            // SAFETY: payload may change under us; the v1 == v2
174            // check below verifies consistency.
175            let value: T = unsafe {
176                let src = header.payload.as_ptr() as *const T;
177                std::ptr::read_unaligned(src)
178            };
179            let v2 = header.version.load(Ordering::Acquire);
180            if v1 == v2 {
181                self.ring_sidecar.push_op(
182                    crate::sidecar_ops::cell::OP_GET,
183                    if retries > 0 { 1 } else { 0 },
184                );
185                return value;
186            }
187            // Writer concurrent with our read; retry.
188            retries = retries.saturating_add(1);
189            std::hint::spin_loop();
190        }
191    }
192
193    pub fn version(&self) -> u32 {
194        self.header().version.load(Ordering::Acquire)
195    }
196
197    /// Non-blocking flush: schedules a writeback via the OS.
198    /// Note: Windows is only partially async (sync to page cache,
199    /// not to disk).
200    pub fn flush_async(&self) -> Result<(), SharedCellError> {
201        self.mmap.flush_async()?;
202        Ok(())
203    }
204
205    pub fn flush(&self) -> Result<(), SharedCellError> {
206        self.mmap.flush()?;
207        Ok(())
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    fn tmp(name: &str) -> std::path::PathBuf {
216        let mut p = std::env::temp_dir();
217        let pid = std::process::id();
218        p.push(format!("subetha-cell-{name}-{pid}.bin"));
219        p
220    }
221
222    #[test]
223    fn round_trip_simple_payload() {
224        let p = tmp("round-trip");
225        let c: SharedCell<u64> = SharedCell::create(&p).unwrap();
226        c.set(42);
227        assert_eq!(c.get(), 42);
228        c.set(99);
229        assert_eq!(c.get(), 99);
230        std::fs::remove_file(&p).ok();
231    }
232
233    #[test]
234    fn cross_handle_visibility() {
235        let p = tmp("cross-handle");
236        let writer: SharedCell<u64> = SharedCell::create(&p).unwrap();
237        let reader: SharedCell<u64> = SharedCell::open(&p).unwrap();
238        writer.set(0xDEAD_BEEF);
239        assert_eq!(reader.get(), 0xDEAD_BEEF);
240        std::fs::remove_file(&p).ok();
241    }
242
243    #[test]
244    fn version_advances_on_each_set() {
245        let p = tmp("version");
246        let c: SharedCell<u32> = SharedCell::create(&p).unwrap();
247        let v0 = c.version();
248        c.set(1);
249        let v1 = c.version();
250        c.set(2);
251        let v2 = c.version();
252        // Each set advances by 2 (odd-then-even).
253        assert_eq!(v1, v0 + 2);
254        assert_eq!(v2, v0 + 4);
255        std::fs::remove_file(&p).ok();
256    }
257
258    #[test]
259    fn disk_persistence_survives_reopen() {
260        let p = tmp("disk-persist");
261        {
262            let c: SharedCell<u64> = SharedCell::create(&p).unwrap();
263            c.set(7777);
264            c.flush().unwrap();
265        }
266        let c2: SharedCell<u64> = SharedCell::open(&p).unwrap();
267        assert_eq!(c2.get(), 7777);
268        std::fs::remove_file(&p).ok();
269    }
270
271    #[test]
272    fn open_rejects_wrong_payload_size() {
273        let p = tmp("wrong-size");
274        let _c: SharedCell<u64> = SharedCell::create(&p).unwrap();
275        match SharedCell::<u32>::open(&p) {
276            Err(SharedCellError::LayoutMismatch) => {}
277            other => panic!("expected LayoutMismatch, got {:?}", other.as_ref().err()),
278        }
279        std::fs::remove_file(&p).ok();
280    }
281
282    #[test]
283    fn struct_payload_round_trip() {
284        #[derive(Clone, Copy, Debug, PartialEq)]
285        #[repr(C)]
286        struct Point { x: f64, y: f64, z: f64 }
287        let p = tmp("struct");
288        let c: SharedCell<Point> = SharedCell::create(&p).unwrap();
289        let pt = Point { x: 1.0, y: 2.0, z: 3.0 };
290        c.set(pt);
291        assert_eq!(c.get(), pt);
292        std::fs::remove_file(&p).ok();
293    }
294
295    #[test]
296    fn concurrent_readers_during_writes() {
297        use std::sync::Arc;
298        use std::thread;
299        let p = tmp("concurrent-rw");
300        let c: Arc<SharedCell<u64>> = Arc::new(SharedCell::create(&p).unwrap());
301        c.set(0);
302        let writer_c = c.clone();
303        let writer = thread::spawn(move || {
304            for i in 1..1000u64 {
305                writer_c.set(i);
306            }
307            999u64
308        });
309        let mut handles = vec![];
310        for _ in 0..4 {
311            let reader_c = c.clone();
312            handles.push(thread::spawn(move || {
313                let mut last = 0u64;
314                for _ in 0..1000 {
315                    let v = reader_c.get();
316                    // Values must be monotonic (writer never goes backwards).
317                    assert!(v >= last, "torn read detected: v={v} last={last}");
318                    last = v;
319                }
320            }));
321        }
322        let final_w = writer.join().unwrap();
323        for h in handles { h.join().unwrap(); }
324        assert!(c.get() >= final_w);
325        std::fs::remove_file(&p).ok();
326    }
327
328    #[test]
329    fn payload_too_large_at_create() {
330        #[allow(dead_code)] // size_of<Big> is the test signal, not the field
331        struct Big([u8; PAYLOAD_BYTES + 1]);
332        impl Copy for Big {}
333        impl Clone for Big { fn clone(&self) -> Self { *self } }
334        let p = tmp("too-large");
335        match SharedCell::<Big>::create(&p) {
336            Err(SharedCellError::PayloadTooLarge) => {}
337            other => panic!("expected PayloadTooLarge, got {:?}", other.as_ref().err()),
338        }
339        std::fs::remove_file(&p).ok();
340    }
341}