forge_alloc/hardening/watermark.rs
1//! `Watermark<I, H>` — monitors allocation utilization in bytes and fires
2//! callbacks at configurable thresholds (warn / critical / oom).
3//!
4//! See `docs/ARCHITECTURE.md` for the composable-wrapper design.
5
6use core::ptr::NonNull;
7use core::sync::atomic::{AtomicUsize, Ordering};
8
9use forge_alloc_core::{AllocError, Allocator, Deallocator, FixedRange, NonZeroLayout};
10
11#[cfg(target_has_atomic = "ptr")]
12use forge_alloc_core::{CachePadded, CACHE_LINE};
13
14/// Severity bucket emitted to a [`WatermarkHandler`].
15#[derive(Copy, Clone, Debug, PartialEq, Eq)]
16pub enum WatermarkLevel {
17 /// Soft warning — usage crossed `warn_pct`.
18 Warn,
19 /// Hard warning — usage crossed `critical_pct`.
20 Critical,
21 /// Out of memory — allocation failed.
22 Oom,
23}
24
25/// Threshold percentages for the warn / critical levels.
26///
27/// The OOM level fires when the allocator returns `AllocError`, regardless
28/// of percentage. Default is `warn_pct = 75`, `critical_pct = 90`.
29#[derive(Copy, Clone, Debug, PartialEq, Eq)]
30pub struct WatermarkThresholds {
31 /// Fire `on_warn` when `bytes_allocated / capacity_bytes` first exceeds
32 /// this percentage. Default 75.
33 pub warn_pct: u8,
34 /// Fire `on_critical` at this percentage. Default 90.
35 pub critical_pct: u8,
36}
37
38impl Default for WatermarkThresholds {
39 fn default() -> Self {
40 Self {
41 warn_pct: 75,
42 critical_pct: 90,
43 }
44 }
45}
46
47/// Snapshot of allocation state when a [`WatermarkHandler`] fires.
48#[derive(Copy, Clone, Debug)]
49pub struct WatermarkEvent {
50 /// Which threshold tripped.
51 pub level: WatermarkLevel,
52 /// Bytes currently held by live allocations (post-update for the call
53 /// that triggered this event).
54 pub allocated_bytes: usize,
55 /// Capacity reported by the inner allocator at this moment.
56 pub capacity_bytes: usize,
57 /// The layout that triggered the event, when relevant. `Some` on
58 /// `on_oom`; `None` on `on_warn` / `on_critical`.
59 pub requested_layout: Option<NonZeroLayout>,
60}
61
62/// Callback handler invoked on threshold crossings and on OOM.
63///
64/// Implementations must be cheap — they run on the allocation hot path.
65/// Set a flag, write to a non-blocking channel, or increment a metric;
66/// avoid blocking I/O.
67///
68/// # Panic safety
69///
70/// Handler methods MUST NOT panic. If `on_warn` / `on_critical` panics
71/// after the inner allocator has already issued a block, `Watermark`
72/// unwinds out of `allocate` **without returning the block to the
73/// caller** — the slot is carved on the inner allocator and the
74/// `allocated` counter has been incremented, but the caller never
75/// receives the pointer and so can never free it. The block is leaked
76/// for the lifetime of the inner allocator. If `on_oom` panics, the
77/// caller's `AllocError` is replaced by the panic; no block was issued
78/// and the counters are unchanged, so this case is consistent but
79/// noisier.
80///
81/// If your handler can fail (e.g. it writes to a possibly-full
82/// channel), absorb the failure inside the handler — set a flag, log,
83/// silently drop the event — rather than letting it escape. Treat
84/// handler-level panics as a fatal bug in your monitoring code.
85pub trait WatermarkHandler: Send + Sync {
86 /// Called once per crossing of the `warn_pct` threshold (rising edge).
87 fn on_warn(&self, event: WatermarkEvent);
88 /// Called once per crossing of the `critical_pct` threshold (rising edge).
89 fn on_critical(&self, event: WatermarkEvent);
90 /// Called when an allocation returns `AllocError`.
91 fn on_oom(&self, event: WatermarkEvent);
92}
93
94/// Discards every event. Zero-overhead substitute when monitoring is
95/// disabled in a release build.
96#[derive(Copy, Clone, Debug, Default)]
97pub struct NullHandler;
98
99impl WatermarkHandler for NullHandler {
100 #[inline]
101 fn on_warn(&self, _event: WatermarkEvent) {}
102 #[inline]
103 fn on_critical(&self, _event: WatermarkEvent) {}
104 #[inline]
105 fn on_oom(&self, _event: WatermarkEvent) {}
106}
107
108/// Emits one `eprintln!` line per event. Suitable for development; not
109/// recommended for production hot paths (stderr writes hit a global lock
110/// in libstd).
111#[cfg(feature = "std")]
112#[derive(Copy, Clone, Debug, Default)]
113pub struct LogHandler;
114
115#[cfg(feature = "std")]
116impl WatermarkHandler for LogHandler {
117 fn on_warn(&self, event: WatermarkEvent) {
118 // Promote to u128 before multiplication to avoid usize overflow on
119 // 32-bit targets where allocated_bytes * 100 can wrap for any
120 // allocation > 42 MiB.
121 let pct = (event.allocated_bytes as u128) * 100 / (event.capacity_bytes.max(1) as u128);
122 eprintln!(
123 "[forge-alloc] watermark WARN: {}/{} bytes ({}%)",
124 event.allocated_bytes, event.capacity_bytes, pct,
125 );
126 }
127 fn on_critical(&self, event: WatermarkEvent) {
128 let pct = (event.allocated_bytes as u128) * 100 / (event.capacity_bytes.max(1) as u128);
129 eprintln!(
130 "[forge-alloc] watermark CRITICAL: {}/{} bytes ({}%)",
131 event.allocated_bytes, event.capacity_bytes, pct,
132 );
133 }
134 fn on_oom(&self, event: WatermarkEvent) {
135 eprintln!(
136 "[forge-alloc] watermark OOM: requested {:?}; {}/{} bytes in use",
137 event.requested_layout, event.allocated_bytes, event.capacity_bytes,
138 );
139 }
140}
141
142/// Dispatches every event to a user-supplied closure. The closure receives
143/// the level via `event.level`.
144pub struct FnHandler<F>(pub F);
145
146impl<F> WatermarkHandler for FnHandler<F>
147where
148 F: Fn(WatermarkEvent) + Send + Sync,
149{
150 fn on_warn(&self, event: WatermarkEvent) {
151 self.0(event);
152 }
153 fn on_critical(&self, event: WatermarkEvent) {
154 self.0(event);
155 }
156 fn on_oom(&self, event: WatermarkEvent) {
157 self.0(event);
158 }
159}
160
161/// Watermark wrapper.
162///
163/// Tracks live bytes in an `AtomicUsize`. Fires the handler on rising
164/// crossings of `warn_pct` and `critical_pct`, and on OOM. Falling edges
165/// (allocations released) do NOT reset the crossing flags — once a region
166/// reaches warn, it stays armed until manual reset (see `rearm()`).
167///
168/// Atomic variant only; non-atomic variant for single-core no_std targets
169/// will land later. Gated by `cfg(target_has_atomic = "ptr")`.
170#[cfg(target_has_atomic = "ptr")]
171pub struct Watermark<I, H> {
172 inner: I,
173 handler: H,
174 thresholds: WatermarkThresholds,
175 capacity_bytes: usize,
176 /// Pre-computed absolute byte threshold = `warn_pct * capacity_bytes /
177 /// 100`. Allocate's hot path compares `new_bytes` against this gate
178 /// before issuing the (out-of-line, `#[cold]`) `check_and_fire` call;
179 /// the common case (well below warn) skips the call entirely. For
180 /// growing inners (ExtendableSlab) the construction-time capacity is
181 /// a lower bound on future capacity, so using it here gives a tight,
182 /// false-positive-only gate — never a missed crossing.
183 ///
184 /// `usize::MAX` when the inner reports unbounded capacity; the gate
185 /// then never fires below saturation, which matches "no thresholds
186 /// configured" semantics.
187 warn_threshold_bytes: usize,
188 /// Live bytes counter. Written on every allocate and deallocate from
189 /// any thread when `Watermark` wraps a `Sync` inner (`SharedBumpArena`,
190 /// `SlabOwner`); kept on its own cache line so concurrent writers
191 /// don't ping-pong against the read-only header fields above or the
192 /// `fired` bitmap below. See [`CachePadded`].
193 allocated: CachePadded<AtomicUsize>,
194 /// Bit 0 = warn fired; bit 1 = critical fired. Set on rising edge to
195 /// prevent re-firing on every allocate. On its own cache line so the
196 /// rising-edge `fetch_or` in `check_and_fire` does not invalidate the
197 /// `allocated` counter's line on every threshold crossing.
198 fired: CachePadded<AtomicUsize>,
199}
200
201#[cfg(target_has_atomic = "ptr")]
202const FIRED_WARN: usize = 1;
203#[cfg(target_has_atomic = "ptr")]
204const FIRED_CRITICAL: usize = 2;
205
206#[cfg(target_has_atomic = "ptr")]
207impl<I, H> Watermark<I, H> {
208 /// Layout-pin: `allocated` (hammered every allocate/dealloc) and `fired`
209 /// (set on threshold crossings) must occupy different cache lines so
210 /// the rising-edge `fetch_or` on `fired` does not invalidate the
211 /// `allocated` line on every concurrent allocate. Forced to evaluate
212 /// by reference in [`with_thresholds`](Self::with_thresholds).
213 const LAYOUT_PIN: () = {
214 use core::mem::offset_of;
215 let a = offset_of!(Watermark<I, H>, allocated);
216 let f = offset_of!(Watermark<I, H>, fired);
217 assert!(
218 a / CACHE_LINE != f / CACHE_LINE,
219 "Watermark layout regression: `allocated` and `fired` share a cache line",
220 );
221 };
222}
223
224#[cfg(target_has_atomic = "ptr")]
225impl<I: Allocator, H: WatermarkHandler> Watermark<I, H> {
226 /// Wrap with default thresholds (75% / 90%).
227 pub fn new(inner: I, handler: H) -> Self {
228 Self::with_thresholds(inner, handler, WatermarkThresholds::default())
229 }
230
231 /// Wrap with explicit thresholds.
232 pub fn with_thresholds(inner: I, handler: H, thresholds: WatermarkThresholds) -> Self {
233 // Force evaluation of the layout-pin const for this (I, H).
234 let _: () = Self::LAYOUT_PIN;
235 // Snapshot capacity at construction. Watermark re-queries via the
236 // capacity_bytes() method below so growing inners (e.g.
237 // ExtendableSlab) report the live value to handlers.
238 let capacity_bytes = inner.capacity_bytes().unwrap_or(usize::MAX);
239 // Pre-compute the hot-path gate so `allocate` can skip the
240 // `check_and_fire` call entirely while utilization is below
241 // *every* configured threshold. Take the **min** of warn_pct
242 // and critical_pct so a config with `critical_pct < warn_pct`
243 // (caller mistake — but not rejected at construction since the
244 // type-level invariant only says both are u8) doesn't silently
245 // suppress critical events that fall above the critical line
246 // but below warn. Pinned by the regression test
247 // `inverted_thresholds_hot_path_gate_does_not_suppress_critical`.
248 //
249 // Computed in `u128` to avoid overflow on 32-bit targets where
250 // `capacity_bytes * 100` could wrap. For unbounded inners we
251 // keep `usize::MAX` so the gate never fires.
252 let warn_threshold_bytes = if capacity_bytes == usize::MAX {
253 usize::MAX
254 } else {
255 // Pct values are u8 (0..=100 in normal use); guard against
256 // pathological >100 values by saturating.
257 let warn_pct = thresholds.warn_pct.min(100) as u128;
258 let critical_pct = thresholds.critical_pct.min(100) as u128;
259 let pct = warn_pct.min(critical_pct);
260 ((capacity_bytes as u128 * pct) / 100) as usize
261 };
262 Self {
263 inner,
264 handler,
265 thresholds,
266 capacity_bytes,
267 warn_threshold_bytes,
268 allocated: CachePadded::new(AtomicUsize::new(0)),
269 fired: CachePadded::new(AtomicUsize::new(0)),
270 }
271 }
272
273 /// Bytes currently in use.
274 #[inline]
275 pub fn allocated_bytes(&self) -> usize {
276 // Telemetry read: no synchronization with allocator state needed.
277 self.allocated.load(Ordering::Relaxed)
278 }
279
280 /// Borrow the inner allocator.
281 #[inline]
282 pub fn inner(&self) -> &I {
283 &self.inner
284 }
285
286 /// Borrow the handler.
287 #[inline]
288 pub fn handler(&self) -> &H {
289 &self.handler
290 }
291
292 /// Re-arm threshold firing (clears warn/critical fired flags). Call
293 /// after addressing a high-water condition if you want the next
294 /// crossing to fire again.
295 #[inline]
296 pub fn rearm(&self) {
297 // No memory is published through `fired`; it's a self-contained
298 // test-and-set bitmap. Relaxed is sufficient.
299 self.fired.store(0, Ordering::Relaxed);
300 }
301
302 /// Resolved capacity (queried fresh from inner per call so growing
303 /// inners give accurate readings).
304 #[inline]
305 fn current_capacity(&self) -> usize {
306 self.inner.capacity_bytes().unwrap_or(self.capacity_bytes)
307 }
308
309 /// Check whether the post-allocate `new_bytes` value crossed a threshold
310 /// and fire the appropriate handler. Returns the snapshot used.
311 ///
312 /// Marked `#[cold]` + `#[inline(never)]` because threshold-crossing
313 /// is rare (only happens on the few allocates that push past 75% /
314 /// 90% utilization); keeping this body out of the per-allocate hot
315 /// path shrinks the i-cache footprint of `Allocator::allocate` for
316 /// every common-case alloc that does NOT cross a threshold.
317 #[cold]
318 #[inline(never)]
319 fn check_and_fire(&self, new_bytes: usize, requested: Option<NonZeroLayout>) {
320 let capacity = self.current_capacity();
321 if capacity == 0 {
322 return;
323 }
324 // Compute percentage as (new_bytes * 100 / capacity); saturate to
325 // u8::MAX so values >100% (possible if the inner allocator hands
326 // back more bytes than capacity_bytes() reports) still fire any
327 // configured threshold.
328 let pct_u128 = (new_bytes as u128) * 100 / (capacity as u128);
329 let pct = pct_u128.min(255) as u8;
330
331 // Critical first (higher priority).
332 if pct >= self.thresholds.critical_pct {
333 // Atomic test-and-set: `fetch_or` is a single RMW, so two
334 // racing threads cannot both observe `prev & FIRED_CRITICAL ==
335 // 0` — at most one fires the rising-edge handler. We also set
336 // FIRED_WARN in the same op: if we jumped from below-warn
337 // straight to critical, the warn-rising-edge has been
338 // implicitly subsumed and we don't want a later dip below
339 // critical to spuriously re-fire `on_warn`.
340 // `fetch_or` atomicity alone gives the rising-edge guarantee:
341 // only the thread that observes `prev & FIRED_CRITICAL == 0`
342 // fires the handler. The handler reads only the
343 // locally-constructed `WatermarkEvent`, so no payload memory
344 // is published through this RMW — Relaxed is correct.
345 let prev = self
346 .fired
347 .fetch_or(FIRED_CRITICAL | FIRED_WARN, Ordering::Relaxed);
348 if prev & FIRED_CRITICAL == 0 {
349 self.handler.on_critical(WatermarkEvent {
350 level: WatermarkLevel::Critical,
351 allocated_bytes: new_bytes,
352 capacity_bytes: capacity,
353 requested_layout: requested,
354 });
355 }
356 } else if pct >= self.thresholds.warn_pct {
357 // Same rising-edge reasoning as above; Relaxed suffices.
358 let prev = self.fired.fetch_or(FIRED_WARN, Ordering::Relaxed);
359 if prev & FIRED_WARN == 0 {
360 self.handler.on_warn(WatermarkEvent {
361 level: WatermarkLevel::Warn,
362 allocated_bytes: new_bytes,
363 capacity_bytes: capacity,
364 requested_layout: requested,
365 });
366 }
367 }
368 }
369}
370
371#[cfg(target_has_atomic = "ptr")]
372unsafe impl<I: Allocator, H: WatermarkHandler> Deallocator for Watermark<I, H> {
373 #[inline]
374 unsafe fn deallocate(&self, ptr: NonNull<u8>, layout: NonZeroLayout) {
375 // Per Deallocator's contract `ptr` came from a previous `allocate`
376 // on `self`, so the pre-sub value must be >= layout.size(). A
377 // mismatched dealloc would wrap the counter; catch it in debug.
378 // Hot-path: a single `fetch_sub` (one `lock xadd` on x86_64)
379 // replaces the previous `fetch_update` CAS loop. The CAS loop
380 // saturated at zero to defend against UB caller bugs, but under
381 // contention from N threads each conflicting RMW caused another
382 // retry — making dealloc cost scale with thread count. For a
383 // correct caller the contract guarantees `prev >= size`, so no
384 // saturation is needed on the happy path. Debug builds still
385 // catch the UB caller bug via `debug_assert!` below.
386 let size = layout.size().get();
387 let prev = self.allocated.fetch_sub(size, Ordering::Relaxed);
388 debug_assert!(
389 prev >= size,
390 "Watermark::deallocate underflow: prev={prev}, size={size}",
391 );
392 // SAFETY: forwarded; caller upholds Deallocator contract.
393 unsafe { self.inner.deallocate(ptr, layout) };
394 }
395}
396
397#[cfg(target_has_atomic = "ptr")]
398unsafe impl<I: Allocator, H: WatermarkHandler> Allocator for Watermark<I, H> {
399 #[inline]
400 fn allocate(&self, layout: NonZeroLayout) -> Result<NonNull<[u8]>, AllocError> {
401 match self.inner.allocate(layout) {
402 Ok(block) => {
403 // Counter only; no data dependency crosses this RMW.
404 // `saturating_add` instead of `+` so a near-`usize::MAX`
405 // `prev` (only reachable via wrap-around after a mismatched
406 // dealloc — UB caller bug — but defended against here so we
407 // do not turn that bug into a debug-mode panic in this
408 // wrapper) does not blow up the debug build.
409 let size = layout.size().get();
410 let prev = self.allocated.fetch_add(size, Ordering::Relaxed);
411 let new_bytes = prev.saturating_add(size);
412 // Hot-path gate: skip the `#[cold]` `check_and_fire` call
413 // entirely while we're below the construction-time warn
414 // threshold. `check_and_fire` is `#[cold] #[inline(never)]`
415 // but an unconditional call still costs the args setup +
416 // branch instruction every allocate. For growing inners
417 // the construction-time capacity is a lower bound on
418 // future capacity, so the threshold computed from it is
419 // always at least as low as the live threshold — false
420 // positive (call when not yet warn) at worst, never a
421 // missed crossing.
422 if new_bytes >= self.warn_threshold_bytes {
423 self.check_and_fire(new_bytes, None);
424 }
425 Ok(block)
426 }
427 Err(e) => {
428 let cur = self.allocated_bytes();
429 self.handler.on_oom(WatermarkEvent {
430 level: WatermarkLevel::Oom,
431 allocated_bytes: cur,
432 capacity_bytes: self.current_capacity(),
433 requested_layout: Some(layout),
434 });
435 Err(e)
436 }
437 }
438 }
439
440 #[inline]
441 unsafe fn usable_size(&self, ptr: NonNull<u8>, layout: NonZeroLayout) -> Option<usize> {
442 // Layout-transparent forwarder: `allocate` returns the inner's block
443 // unchanged, so forward `usable_size` too — otherwise an outer scrub
444 // wrapper (`PoisonOnFree`/`ZeroizeOnFree`) over `Watermark` would see
445 // `None` and leave the slack tail un-scrubbed.
446 // SAFETY: forwarded; caller upholds usable_size's contract on inner.
447 unsafe { self.inner.usable_size(ptr, layout) }
448 }
449
450 #[inline]
451 fn capacity_bytes(&self) -> Option<usize> {
452 self.inner.capacity_bytes()
453 }
454
455 #[inline]
456 fn corruption_events(&self) -> u64 {
457 self.inner.corruption_events()
458 }
459}
460
461#[cfg(target_has_atomic = "ptr")]
462impl<I: FixedRange, H: WatermarkHandler> FixedRange for Watermark<I, H> {
463 #[inline]
464 fn base(&self) -> NonNull<u8> {
465 self.inner.base()
466 }
467
468 #[inline]
469 fn size(&self) -> usize {
470 self.inner.size()
471 }
472
473 /// Pass-through forward so a `commit`-aware consumer reaches the inner
474 /// backing when this wrapper sits over a `lazy_commit` `MmapBacked`.
475 #[inline]
476 fn commit(&self, offset: usize, len: usize) -> Result<(), AllocError> {
477 self.inner.commit(offset, len)
478 }
479}
480
481#[cfg(test)]
482#[cfg(target_has_atomic = "ptr")]
483mod tests {
484 use super::*;
485 use crate::backing::InlineBacked;
486 use crate::layout::BumpArena;
487 use core::sync::atomic::AtomicU8;
488
489 /// Handler that records which callbacks fired, for assertion in tests.
490 #[derive(Default)]
491 struct FlagHandler {
492 warn: AtomicU8,
493 critical: AtomicU8,
494 oom: AtomicU8,
495 }
496
497 impl WatermarkHandler for FlagHandler {
498 fn on_warn(&self, _event: WatermarkEvent) {
499 self.warn.fetch_add(1, Ordering::Relaxed);
500 }
501 fn on_critical(&self, _event: WatermarkEvent) {
502 self.critical.fetch_add(1, Ordering::Relaxed);
503 }
504 fn on_oom(&self, _event: WatermarkEvent) {
505 self.oom.fetch_add(1, Ordering::Relaxed);
506 }
507 }
508
509 fn build() -> Watermark<BumpArena<InlineBacked<1024>>, FlagHandler> {
510 Watermark::new(
511 BumpArena::new(InlineBacked::<1024>::new()).unwrap(),
512 FlagHandler::default(),
513 )
514 }
515
516 #[test]
517 fn warn_fires_at_75_pct() {
518 let w = build();
519 // 75% of 1024 = 768. Allocate 800 bytes (well past warn).
520 let layout = NonZeroLayout::from_size_align(800, 1).unwrap();
521 let _ = w.allocate(layout).unwrap();
522 assert_eq!(w.handler().warn.load(Ordering::Relaxed), 1);
523 // Critical (90% = 921) NOT fired.
524 assert_eq!(w.handler().critical.load(Ordering::Relaxed), 0);
525 }
526
527 #[test]
528 fn critical_fires_at_90_pct() {
529 let w = build();
530 let layout = NonZeroLayout::from_size_align(950, 1).unwrap();
531 let _ = w.allocate(layout).unwrap();
532 assert_eq!(w.handler().critical.load(Ordering::Relaxed), 1);
533 }
534
535 #[test]
536 fn warn_fires_only_once() {
537 let w = build();
538 let layout = NonZeroLayout::from_size_align(200, 1).unwrap();
539 // 800 bytes total → past warn (768).
540 for _ in 0..4 {
541 let _ = w.allocate(layout).unwrap();
542 }
543 assert_eq!(w.handler().warn.load(Ordering::Relaxed), 1);
544 }
545
546 #[test]
547 fn oom_fires_on_alloc_error() {
548 let w = build();
549 // Allocate beyond capacity.
550 let layout = NonZeroLayout::from_size_align(2048, 1).unwrap();
551 assert!(w.allocate(layout).is_err());
552 assert_eq!(w.handler().oom.load(Ordering::Relaxed), 1);
553 }
554
555 #[test]
556 fn dealloc_subtracts_from_allocated() {
557 let w = Watermark::new(
558 crate::layout::Slab::<u64, InlineBacked<512>>::new(8, InlineBacked::<512>::new())
559 .unwrap(),
560 FlagHandler::default(),
561 );
562 let layout = NonZeroLayout::for_type::<u64>().unwrap();
563 let block = w.allocate(layout).unwrap();
564 assert_eq!(w.allocated_bytes(), 8);
565 unsafe { w.deallocate(block.cast(), layout) };
566 assert_eq!(w.allocated_bytes(), 0);
567 }
568
569 #[test]
570 fn rearm_clears_fired_flags() {
571 let w = build();
572 let layout = NonZeroLayout::from_size_align(800, 1).unwrap();
573 let _ = w.allocate(layout).unwrap();
574 assert_eq!(w.handler().warn.load(Ordering::Relaxed), 1);
575 w.rearm();
576 // Reset the bump arena state isn't relevant here — we just need
577 // another rising edge. Allocate a tiny bit more to push past warn
578 // threshold once flags are re-armed; warn fires again.
579 // (Already at 800/1024 = 78%, still above warn. Next alloc just
580 // re-trips the check since flags are clear.)
581 let small = NonZeroLayout::from_size_align(1, 1).unwrap();
582 let _ = w.allocate(small).unwrap();
583 assert_eq!(w.handler().warn.load(Ordering::Relaxed), 2);
584 }
585
586 #[test]
587 fn null_handler_zero_cost() {
588 let nh = NullHandler;
589 nh.on_warn(WatermarkEvent {
590 level: WatermarkLevel::Warn,
591 allocated_bytes: 0,
592 capacity_bytes: 0,
593 requested_layout: None,
594 });
595 }
596
597 /// Boundary: `warn_pct = 0` fires on every allocation, but only
598 /// once (rising-edge gate). Verify: a single alloc fires `warn`,
599 /// subsequent allocs do not refire.
600 #[test]
601 fn warn_pct_zero_fires_on_first_alloc_only() {
602 let inner = BumpArena::new(InlineBacked::<1024>::new()).unwrap();
603 let w = Watermark::with_thresholds(
604 inner,
605 FlagHandler::default(),
606 WatermarkThresholds {
607 warn_pct: 0,
608 critical_pct: 90,
609 },
610 );
611 let small = NonZeroLayout::from_size_align(1, 1).unwrap();
612 for _ in 0..4 {
613 let _ = w.allocate(small).unwrap();
614 }
615 // First alloc tripped warn (0% threshold met); next three are
616 // suppressed by the FIRED_WARN bit.
617 assert_eq!(w.handler().warn.load(Ordering::Relaxed), 1);
618 assert_eq!(w.handler().critical.load(Ordering::Relaxed), 0);
619 }
620
621 /// Boundary: `warn_pct = 100` fires only at exact saturation (where
622 /// OOM is imminent). Verify no warn fires below 100% usage.
623 #[test]
624 fn warn_pct_100_does_not_fire_below_saturation() {
625 let inner = BumpArena::new(InlineBacked::<1024>::new()).unwrap();
626 let w = Watermark::with_thresholds(
627 inner,
628 FlagHandler::default(),
629 WatermarkThresholds {
630 warn_pct: 100,
631 critical_pct: 100,
632 },
633 );
634 // ~97.6% utilization (1000/1024) — still below the 100% gate.
635 let layout = NonZeroLayout::from_size_align(1000, 1).unwrap();
636 let _ = w.allocate(layout).unwrap();
637 assert_eq!(
638 w.handler().warn.load(Ordering::Relaxed),
639 0,
640 "warn must NOT fire below 100% when warn_pct = 100",
641 );
642 // Exact saturation (1024/1024 = 100%).
643 let layout = NonZeroLayout::from_size_align(24, 1).unwrap();
644 let _ = w.allocate(layout).unwrap();
645 // At 100% the warn handler can fire.
646 assert!(
647 w.handler().warn.load(Ordering::Relaxed) >= 1
648 || w.handler().critical.load(Ordering::Relaxed) >= 1,
649 "at 100% utilization, warn OR critical should fire",
650 );
651 }
652
653 /// Behavioral pin (NOT a "good thing"): `critical_pct < warn_pct` is
654 /// a configuration error — the hot-path gate is keyed on
655 /// `warn_pct` (precomputed as `warn_threshold_bytes`). With
656 /// inverted thresholds the gate sits ABOVE critical_pct, so the
657 /// `check_and_fire` body never runs while utilization is below
658 /// warn_pct, and the critical handler is silently suppressed for
659 /// allocations between critical_pct and warn_pct. The wrapper does
660 /// not panic; it just under-reports.
661 ///
662 /// If you find this test surprising and want critical-fires-first
663 /// semantics regardless of input order, the fix is to compute the
664 /// hot-path gate as `min(warn_threshold, critical_threshold)` in
665 /// `with_thresholds`. Pinning the current behavior here so the
666 /// regression surfaces explicitly if that hardening is added.
667 #[test]
668 fn inverted_thresholds_hot_path_gate_does_not_suppress_critical() {
669 // Regression for the inverted-threshold suppression bug: with
670 // `critical_pct < warn_pct` (a config mistake but not rejected),
671 // the old warn-keyed hot-path gate sat ABOVE the critical line, so
672 // allocations whose utilization landed in [critical_pct, warn_pct)
673 // silently failed to fire critical. After the fix the gate is
674 // `min(warn_pct, critical_pct)`, so critical events surface as
675 // soon as they should.
676 let inner = BumpArena::new(InlineBacked::<1024>::new()).unwrap();
677 let w = Watermark::with_thresholds(
678 inner,
679 FlagHandler::default(),
680 WatermarkThresholds {
681 warn_pct: 90,
682 critical_pct: 50,
683 },
684 );
685 // 60% (614 bytes) is above critical (50) but below warn (90).
686 // The hot-path gate is keyed on min(50, 90) = 50% = 512 bytes,
687 // so check_and_fire IS reached and critical fires on rising
688 // edge.
689 let layout = NonZeroLayout::from_size_align(614, 1).unwrap();
690 let _ = w.allocate(layout).unwrap();
691 assert_eq!(
692 w.handler().critical.load(Ordering::Relaxed),
693 1,
694 "inverted-threshold fix: critical must fire when usage crosses critical_pct \
695 even though it's below warn_pct (the hot-path gate now uses min of both)",
696 );
697 // Warn does NOT fire at 60% (warn_pct = 90%) — usage hasn't
698 // crossed it yet.
699 assert_eq!(
700 w.handler().warn.load(Ordering::Relaxed),
701 0,
702 "warn must not fire below warn_pct",
703 );
704 // Cross warn (>=921 bytes). Per `check_and_fire`'s design,
705 // warn is *subsumed* once critical has fired — the FIRED_WARN
706 // bit was set when critical latched, so even crossing warn_pct
707 // here does NOT re-fire on_warn. This matches the
708 // monotonic-severity contract (we don't want a dip-then-recross
709 // to re-trigger the lower-severity handler).
710 let layout = NonZeroLayout::from_size_align(400, 1).unwrap();
711 let _ = w.allocate(layout).unwrap(); // total 1014
712 assert_eq!(
713 w.handler().warn.load(Ordering::Relaxed),
714 0,
715 "warn is subsumed once critical fires — see check_and_fire's \
716 intentional `FIRED_CRITICAL | FIRED_WARN` co-set",
717 );
718 // Critical already latched; no re-fire on this allocate either.
719 assert_eq!(w.handler().critical.load(Ordering::Relaxed), 1);
720 }
721
722 #[test]
723 #[cfg(feature = "std")]
724 fn fn_handler_called() {
725 use std::sync::Mutex;
726 let calls = std::sync::Arc::new(Mutex::new(0u32));
727 let calls2 = std::sync::Arc::clone(&calls);
728 let h = FnHandler(move |_event: WatermarkEvent| {
729 *calls2.lock().unwrap() += 1;
730 });
731 let w = Watermark::new(BumpArena::new(InlineBacked::<1024>::new()).unwrap(), h);
732 let _ = w
733 .allocate(NonZeroLayout::from_size_align(800, 1).unwrap())
734 .unwrap();
735 assert_eq!(*calls.lock().unwrap(), 1);
736 }
737}