Skip to main content

moonpool_explorer/
assertion_slots.rs

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