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}