Skip to main content

moonpool_assertions/
slots.rs

1//! Rich assertion slot tracking for the Antithesis-style assertion suite.
2//!
3//! Maintains a fixed-size table of assertion slots. Supports boolean assertions
4//! (always/sometimes/reachable/unreachable), numeric guidance assertions (with
5//! watermark tracking), and compound boolean assertions (sometimes-all with
6//! frontier tracking).
7//!
8//! Each slot is accessed via raw pointer arithmetic on the assertion region
9//! (heap by default, or `MAP_SHARED` memory when an exploration backend installs
10//! one). With multiple fork children running concurrently, `find_or_alloc_slot`
11//! claims slots by atomically writing `msg_hash` before re-scanning, ensuring
12//! concurrent allocators see each other.
13//!
14//! On a "discovery" (first Sometimes/Reachable pass, numeric watermark
15//! improvement, or frontier advance) the accounting calls
16//! [`crate::hooks::on_slot_discovery`]. With no hook installed this is a no-op
17//! (pure accounting); the exploration backend wires it to coverage + forking.
18
19use std::sync::atomic::{AtomicI64, AtomicU8, AtomicU32, Ordering};
20
21/// Maximum number of tracked assertion slots.
22pub const MAX_ASSERTION_SLOTS: usize = 128;
23
24/// Maximum length of the assertion message stored in a slot.
25const SLOT_MSG_LEN: usize = 64;
26
27/// Total size of the assertion table memory region in bytes.
28///
29/// Layout: `[next_slot: u32, _pad: u32, slots: [AssertionSlot; MAX_ASSERTION_SLOTS]]`
30pub const ASSERTION_TABLE_MEM_SIZE: usize =
31    8 + MAX_ASSERTION_SLOTS * std::mem::size_of::<AssertionSlot>();
32
33/// The kind of assertion being tracked.
34#[repr(u8)]
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum AssertKind {
37    /// Invariant that must always hold when reached.
38    Always = 0,
39    /// Invariant that must hold when reached, but need not be reached.
40    AlwaysOrUnreachable = 1,
41    /// Condition that should sometimes be true.
42    Sometimes = 2,
43    /// Code path that should be reached at least once.
44    Reachable = 3,
45    /// Code path that should never be reached.
46    Unreachable = 4,
47    /// Numeric invariant that must always hold (e.g., val > threshold).
48    NumericAlways = 5,
49    /// Numeric condition that should sometimes hold.
50    NumericSometimes = 6,
51    /// Compound boolean: all named bools should sometimes be true simultaneously.
52    BooleanSometimesAll = 7,
53}
54
55impl AssertKind {
56    /// Convert from raw u8 to `AssertKind`, returning None for invalid values.
57    #[must_use]
58    pub fn from_u8(v: u8) -> Option<Self> {
59        match v {
60            0 => Some(Self::Always),
61            1 => Some(Self::AlwaysOrUnreachable),
62            2 => Some(Self::Sometimes),
63            3 => Some(Self::Reachable),
64            4 => Some(Self::Unreachable),
65            5 => Some(Self::NumericAlways),
66            6 => Some(Self::NumericSometimes),
67            7 => Some(Self::BooleanSometimesAll),
68            _ => None,
69        }
70    }
71}
72
73/// Comparison operator for numeric assertions.
74#[repr(u8)]
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum AssertCmp {
77    /// Greater than.
78    Gt = 0,
79    /// Greater than or equal to.
80    Ge = 1,
81    /// Less than.
82    Lt = 2,
83    /// Less than or equal to.
84    Le = 3,
85}
86
87/// A single assertion tracking slot.
88///
89/// All fields are accessed via raw pointer arithmetic on the assertion region.
90#[repr(C)]
91pub struct AssertionSlot {
92    /// FNV-1a hash of the assertion message (u32).
93    pub msg_hash: u32,
94    /// The kind of assertion (`AssertKind` as u8).
95    pub kind: u8,
96    /// Whether this assertion must be hit (1) or not (0).
97    pub must_hit: u8,
98    /// Whether to maximize (1) or minimize (0) the watermark value.
99    pub maximize: u8,
100    /// Whether a fork has been triggered for this assertion (0 = no, 1 = yes).
101    pub split_triggered: u8,
102    /// Total number of times this assertion passed.
103    pub pass_count: u64,
104    /// Total number of times this assertion failed.
105    pub fail_count: u64,
106    /// Numeric watermark: best value observed (for guidance assertions).
107    pub watermark: i64,
108    /// Watermark value at last fork (for detecting improvement).
109    pub split_watermark: i64,
110    /// Frontier: number of simultaneously true bools (for `BooleanSometimesAll`).
111    pub frontier: u8,
112    /// Padding for alignment.
113    pad: [u8; 7],
114    /// Assertion message string (null-terminated).
115    pub msg: [u8; SLOT_MSG_LEN],
116}
117
118impl AssertionSlot {
119    /// Get the assertion message as a string slice.
120    #[must_use]
121    pub fn msg_str(&self) -> &str {
122        let len = self
123            .msg
124            .iter()
125            .position(|&b| b == 0)
126            .unwrap_or(SLOT_MSG_LEN);
127        std::str::from_utf8(&self.msg[..len]).unwrap_or("???")
128    }
129}
130
131/// FNV-1a hash of a message string to a stable u32.
132#[must_use]
133pub fn msg_hash(msg: &str) -> u32 {
134    let mut h: u32 = 0x811c_9dc5;
135    for b in msg.bytes() {
136        h ^= u32::from(b);
137        h = h.wrapping_mul(0x0100_0193);
138    }
139    h
140}
141
142/// Find an existing slot or allocate a new one by `msg_hash`.
143///
144/// Returns a pointer to the slot and its index, or null if the table is full.
145///
146/// # Safety
147///
148/// `table_ptr` must point to a valid assertion table region of at least
149/// `ASSERTION_TABLE_MEM_SIZE` bytes.
150unsafe fn find_or_alloc_slot(
151    table_ptr: *mut u8,
152    hash: u32,
153    kind: AssertKind,
154    must_hit: u8,
155    maximize: u8,
156    msg: &str,
157) -> (*mut AssertionSlot, usize) {
158    unsafe {
159        let next_atomic = &*table_ptr.cast::<()>().cast::<AtomicU32>();
160        let count = next_atomic.load(Ordering::Acquire) as usize;
161        let base = table_ptr.add(8).cast::<()>().cast::<AssertionSlot>();
162
163        // Search existing slots (atomic load to see concurrent writers).
164        for i in 0..count.min(MAX_ASSERTION_SLOTS) {
165            let slot = base.add(i);
166            let h = &*std::ptr::addr_of!((*slot).msg_hash).cast::<AtomicU32>();
167            if h.load(Ordering::Acquire) == hash {
168                return (slot, i);
169            }
170        }
171
172        // Allocate new slot atomically.
173        let new_idx = next_atomic.fetch_add(1, Ordering::AcqRel) as usize;
174        if new_idx >= MAX_ASSERTION_SLOTS {
175            next_atomic.fetch_sub(1, Ordering::AcqRel);
176            return (std::ptr::null_mut(), 0);
177        }
178
179        // Claim our slot by writing msg_hash atomically BEFORE re-scanning.
180        // This makes our claim visible to any concurrent process doing
181        // its own re-scan, preventing the TOCTOU duplicate slot race.
182        let slot = base.add(new_idx);
183        let hash_atomic = &*std::ptr::addr_of!((*slot).msg_hash).cast::<AtomicU32>();
184        hash_atomic.store(hash, Ordering::Release);
185
186        // Re-scan 0..new_idx for a concurrent registration of the same hash.
187        // Lower index always wins — if we find a match, tombstone ourselves.
188        for i in 0..new_idx {
189            let existing = base.add(i);
190            let existing_hash = &*std::ptr::addr_of!((*existing).msg_hash).cast::<AtomicU32>();
191            if existing_hash.load(Ordering::Acquire) == hash {
192                // Another process claimed a lower slot. Tombstone ours.
193                hash_atomic.store(0, Ordering::Release);
194                std::ptr::write_bytes(slot.cast::<u8>(), 0, std::mem::size_of::<AssertionSlot>());
195                return (existing, i);
196            }
197        }
198
199        // No duplicate found — write remaining slot fields (msg_hash already set).
200        let mut msg_buf = [0u8; SLOT_MSG_LEN];
201        let n = msg.len().min(SLOT_MSG_LEN - 1);
202        msg_buf[..n].copy_from_slice(&msg.as_bytes()[..n]);
203
204        (*slot).kind = kind as u8;
205        (*slot).must_hit = must_hit;
206        (*slot).maximize = maximize;
207        (*slot).split_triggered = 0;
208        (*slot).pass_count = 0;
209        (*slot).fail_count = 0;
210        (*slot).watermark = if maximize == 1 { i64::MIN } else { i64::MAX };
211        (*slot).split_watermark = if maximize == 1 { i64::MIN } else { i64::MAX };
212        (*slot).frontier = 0;
213        (*slot).pad = [0; 7];
214        (*slot).msg = msg_buf;
215
216        (slot, new_idx)
217    }
218}
219
220/// Boolean assertion backing function.
221///
222/// Handles Always, `AlwaysOrUnreachable`, Sometimes, Reachable, and Unreachable.
223/// Gets or allocates a slot, increments pass/fail counts, and signals a discovery
224/// for Sometimes/Reachable assertions on first success.
225///
226/// This is a no-op if the assertion table is not initialized.
227pub fn assertion_bool(kind: AssertKind, must_hit: bool, condition: bool, msg: &str) {
228    let table_ptr = crate::region::assertion_table_ptr();
229    if table_ptr.is_null() {
230        return;
231    }
232
233    let hash = msg_hash(msg);
234    let must_hit_u8 = u8::from(must_hit);
235
236    // Safety: table_ptr points to ASSERTION_TABLE_MEM_SIZE bytes.
237    let (slot, slot_idx) =
238        unsafe { find_or_alloc_slot(table_ptr, hash, kind, must_hit_u8, 0, msg) };
239    if slot.is_null() {
240        return;
241    }
242
243    // Safety: slot points to valid memory.
244    unsafe {
245        match kind {
246            AssertKind::Always | AssertKind::AlwaysOrUnreachable | AssertKind::NumericAlways => {
247                if condition {
248                    let pc = &*(&raw const (*slot).pass_count).cast::<AtomicI64>();
249                    pc.fetch_add(1, Ordering::Relaxed);
250                } else {
251                    let fc = &*(&raw const (*slot).fail_count).cast::<AtomicI64>();
252                    let prev = fc.fetch_add(1, Ordering::Relaxed);
253                    if prev == 0 {
254                        eprintln!("[ASSERTION FAILED] {msg} (kind={kind:?})");
255                    }
256                }
257            }
258            AssertKind::Sometimes | AssertKind::Reachable => {
259                if condition {
260                    let pc = &*(&raw const (*slot).pass_count).cast::<AtomicI64>();
261                    pc.fetch_add(1, Ordering::Relaxed);
262
263                    // CAS split_triggered from 0 → 1 on first success
264                    let ft = &*(&raw const (*slot).split_triggered).cast::<AtomicU8>();
265                    if ft
266                        .compare_exchange(0, 1, Ordering::Relaxed, Ordering::Relaxed)
267                        .is_ok()
268                    {
269                        crate::hooks::on_slot_discovery(slot_idx, hash);
270                    }
271                } else {
272                    let fc = &*(&raw const (*slot).fail_count).cast::<AtomicI64>();
273                    fc.fetch_add(1, Ordering::Relaxed);
274                }
275            }
276            AssertKind::Unreachable => {
277                // Being reached at all is a "pass" (the assertion is that we should NOT reach)
278                // We track it as pass_count = times reached (bad), fail_count unused
279                let pc = &*(&raw const (*slot).pass_count).cast::<AtomicI64>();
280                let prev = pc.fetch_add(1, Ordering::Relaxed);
281                if prev == 0 {
282                    eprintln!("[UNREACHABLE REACHED] {msg}");
283                }
284            }
285            _ => {}
286        }
287    }
288}
289
290/// Numeric guidance assertion backing function.
291///
292/// Evaluates a comparison (left `cmp` right), tracks pass/fail counts,
293/// and maintains a watermark of the best observed value of `left`.
294/// For `NumericSometimes`, signals a discovery when the watermark improves past
295/// the last fork watermark.
296///
297/// `maximize` determines whether improving means getting larger (true) or smaller (false).
298///
299/// This is a no-op if the assertion table is not initialized.
300pub fn assertion_numeric(
301    kind: AssertKind,
302    cmp: AssertCmp,
303    maximize: bool,
304    left: i64,
305    right: i64,
306    msg: &str,
307) {
308    let table_ptr = crate::region::assertion_table_ptr();
309    if table_ptr.is_null() {
310        return;
311    }
312
313    let hash = msg_hash(msg);
314    let maximize_u8 = u8::from(maximize);
315
316    // Safety: table_ptr points to ASSERTION_TABLE_MEM_SIZE bytes.
317    let (slot, slot_idx) =
318        unsafe { find_or_alloc_slot(table_ptr, hash, kind, 1, maximize_u8, msg) };
319    if slot.is_null() {
320        return;
321    }
322
323    // Evaluate the comparison
324    let passes = match cmp {
325        AssertCmp::Gt => left > right,
326        AssertCmp::Ge => left >= right,
327        AssertCmp::Lt => left < right,
328        AssertCmp::Le => left <= right,
329    };
330
331    // Safety: slot points to valid memory.
332    unsafe {
333        if passes {
334            let pc = &*(&raw const (*slot).pass_count).cast::<AtomicI64>();
335            pc.fetch_add(1, Ordering::Relaxed);
336        } else {
337            let fc = &*(&raw const (*slot).fail_count).cast::<AtomicI64>();
338            let prev = fc.fetch_add(1, Ordering::Relaxed);
339            if kind == AssertKind::NumericAlways && prev == 0 {
340                eprintln!(
341                    "[NUMERIC ASSERTION FAILED] {msg} (left={left}, right={right}, cmp={cmp:?})"
342                );
343            }
344        }
345
346        // Update watermark: track best value of `left`
347        let wm = &*(&raw const (*slot).watermark).cast::<AtomicI64>();
348        let mut current = wm.load(Ordering::Relaxed);
349        loop {
350            let is_better = if maximize {
351                left > current
352            } else {
353                left < current
354            };
355            if !is_better {
356                break;
357            }
358            match wm.compare_exchange_weak(current, left, Ordering::Relaxed, Ordering::Relaxed) {
359                Ok(_) => break,
360                Err(actual) => current = actual,
361            }
362        }
363
364        // For NumericSometimes: signal discovery when watermark improves past split_watermark
365        if kind == AssertKind::NumericSometimes {
366            let fw = &*(&raw const (*slot).split_watermark).cast::<AtomicI64>();
367            let mut fork_current = fw.load(Ordering::Relaxed);
368            loop {
369                let is_better = if maximize {
370                    left > fork_current
371                } else {
372                    left < fork_current
373                };
374                if !is_better {
375                    break;
376                }
377                match fw.compare_exchange_weak(
378                    fork_current,
379                    left,
380                    Ordering::Relaxed,
381                    Ordering::Relaxed,
382                ) {
383                    Ok(_) => {
384                        crate::hooks::on_slot_discovery(slot_idx, hash);
385                        break;
386                    }
387                    Err(actual) => fork_current = actual,
388                }
389            }
390        }
391    }
392}
393
394/// Compound boolean assertion backing function (sometimes-all).
395///
396/// Counts how many of the named booleans are simultaneously true.
397/// Maintains a frontier (max count seen). Signals a discovery when the frontier
398/// advances.
399///
400/// This is a no-op if the assertion table is not initialized.
401pub fn assertion_sometimes_all(msg: &str, named_bools: &[(&str, bool)]) {
402    let table_ptr = crate::region::assertion_table_ptr();
403    if table_ptr.is_null() {
404        return;
405    }
406
407    let hash = msg_hash(msg);
408
409    // Safety: table_ptr points to ASSERTION_TABLE_MEM_SIZE bytes.
410    let (slot, slot_idx) =
411        unsafe { find_or_alloc_slot(table_ptr, hash, AssertKind::BooleanSometimesAll, 1, 0, msg) };
412    if slot.is_null() {
413        return;
414    }
415
416    // Count simultaneously true bools. The frontier field is u8, so we cap at u8::MAX —
417    // callers passing more than 255 named bools is not a supported use case; clamp
418    // via `unwrap_or(u8::MAX)` so we never panic.
419    let true_count =
420        u8::try_from(named_bools.iter().filter(|(_, v)| *v).count()).unwrap_or(u8::MAX);
421
422    // Safety: slot points to valid memory.
423    unsafe {
424        // Increment pass_count (always, for statistics)
425        let pc = &*(&raw const (*slot).pass_count).cast::<AtomicI64>();
426        pc.fetch_add(1, Ordering::Relaxed);
427
428        // CAS loop on frontier — signal discovery when it advances
429        let fr = &*(&raw const (*slot).frontier).cast::<AtomicU8>();
430        let mut current = fr.load(Ordering::Relaxed);
431        loop {
432            if true_count <= current {
433                break;
434            }
435            match fr.compare_exchange_weak(
436                current,
437                true_count,
438                Ordering::Relaxed,
439                Ordering::Relaxed,
440            ) {
441                Ok(_) => {
442                    crate::hooks::on_slot_discovery(slot_idx, hash);
443                    break;
444                }
445                Err(actual) => current = actual,
446            }
447        }
448    }
449}
450
451/// Read all allocated assertion slots from the region.
452///
453/// Returns an empty vector if the assertion table is not initialized.
454#[must_use]
455pub fn assertion_read_all() -> Vec<AssertionSlotSnapshot> {
456    let table_ptr = crate::region::assertion_table_ptr();
457    if table_ptr.is_null() {
458        return Vec::new();
459    }
460
461    // Safety: table_ptr was allocated with ASSERTION_TABLE_MEM_SIZE bytes.
462    // - The first 4 bytes hold the slot count (u32), capped at MAX_ASSERTION_SLOTS.
463    // - base = table_ptr + 8 is the start of the AssertionSlot array.
464    // - Loop bound 0..count ensures base.add(i) stays within the allocated region.
465    // - AssertionSlot fields are read through a shared reference; tombstoned slots
466    //   (msg_hash == 0) are skipped.
467    unsafe {
468        let count = (*table_ptr.cast::<()>().cast::<u32>()) as usize;
469        let count = count.min(MAX_ASSERTION_SLOTS);
470        let base = table_ptr.add(8).cast::<()>().cast::<AssertionSlot>();
471
472        (0..count)
473            .filter_map(|i| {
474                let slot = &*base.add(i);
475                // Skip tombstones (msg_hash == 0) left by the duplicate-slot race fix.
476                if slot.msg_hash == 0 {
477                    return None;
478                }
479                Some(AssertionSlotSnapshot {
480                    msg: slot.msg_str().to_string(),
481                    kind: slot.kind,
482                    must_hit: slot.must_hit,
483                    pass_count: slot.pass_count,
484                    fail_count: slot.fail_count,
485                    watermark: slot.watermark,
486                    frontier: slot.frontier,
487                })
488            })
489            .collect()
490    }
491}
492
493/// A snapshot of an assertion slot for reporting.
494#[derive(Debug, Clone)]
495pub struct AssertionSlotSnapshot {
496    /// The assertion message.
497    pub msg: String,
498    /// The kind of assertion (`AssertKind` as u8).
499    pub kind: u8,
500    /// Whether this assertion must be hit.
501    pub must_hit: u8,
502    /// Number of times the assertion passed.
503    pub pass_count: u64,
504    /// Number of times the assertion failed.
505    pub fail_count: u64,
506    /// Best watermark value (for numeric assertions).
507    pub watermark: i64,
508    /// Frontier value (for `BooleanSometimesAll`).
509    pub frontier: u8,
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn test_msg_hash_deterministic() {
518        let h1 = msg_hash("test_assertion");
519        let h2 = msg_hash("test_assertion");
520        assert_eq!(h1, h2);
521    }
522
523    #[test]
524    fn test_msg_hash_no_collision() {
525        let names = ["a", "b", "c", "timeout", "connect", "retry"];
526        let hashes: Vec<u32> = names.iter().map(|n| msg_hash(n)).collect();
527        for i in 0..hashes.len() {
528            for j in (i + 1)..hashes.len() {
529                assert_ne!(
530                    hashes[i], hashes[j],
531                    "{} and {} collide",
532                    names[i], names[j]
533                );
534            }
535        }
536    }
537
538    #[test]
539    fn test_slot_size_stable() {
540        // Verify AssertionSlot size for shared memory layout stability.
541        // msg_hash(4) + kind(1) + must_hit(1) + maximize(1) + split_triggered(1) +
542        // pass_count(8) + fail_count(8) + watermark(8) + split_watermark(8) +
543        // frontier(1) + _pad(7) + msg(64) = 112
544        assert_eq!(std::mem::size_of::<AssertionSlot>(), 112);
545    }
546
547    #[test]
548    fn test_assertion_bool_noop_when_inactive() {
549        // Should not panic when assertion table is not initialized.
550        assertion_bool(AssertKind::Sometimes, true, true, "test");
551        assertion_bool(AssertKind::Always, true, false, "test2");
552    }
553
554    #[test]
555    fn test_assertion_numeric_noop_when_inactive() {
556        // Should not panic when assertion table is not initialized.
557        assertion_numeric(
558            AssertKind::NumericAlways,
559            AssertCmp::Gt,
560            false,
561            10,
562            5,
563            "test",
564        );
565    }
566
567    #[test]
568    fn test_assert_kind_from_u8() {
569        assert_eq!(AssertKind::from_u8(0), Some(AssertKind::Always));
570        assert_eq!(
571            AssertKind::from_u8(7),
572            Some(AssertKind::BooleanSometimesAll)
573        );
574        assert_eq!(AssertKind::from_u8(8), None);
575    }
576}