Skip to main content

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}