Skip to main content

forge_alloc/layout/
fallback.rs

1//! `WithFallback<P, S>` — try the primary allocator; on `AllocError`, fall
2//! back to the secondary. Deallocation routes by pointer-provenance via
3//! [`forge_alloc_core::FixedRange::contains`].
4//!
5//! Typical pattern: `WithFallback<BumpArena<InlineBacked<N>>, System>` —
6//! stack-fast for the common case, global heap for overflow.
7//!
8//! ```
9//! # #[cfg(feature = "std")]
10//! # {
11//! use forge_alloc::{InlineBacked, System};
12//! use forge_alloc::{Allocator, Deallocator, NonZeroLayout};
13//! use forge_alloc::WithFallback;
14//!
15//! let alloc = WithFallback::new(InlineBacked::<256>::new(), System);
16//! // Small request — served by the inline primary.
17//! let small = NonZeroLayout::from_size_align(64, 8).unwrap();
18//! let block = alloc.allocate(small).unwrap();
19//! unsafe { alloc.deallocate(block.cast(), small) };
20//! # }
21//! ```
22//!
23//! See `docs/ARCHITECTURE.md` for the fallback-router design.
24
25use core::ptr::NonNull;
26
27use forge_alloc_core::{AllocError, Allocator, Deallocator, FixedRange, NonZeroLayout};
28
29/// Router with primary + secondary allocator.
30///
31/// `Primary` must implement [`FixedRange`] so that deallocation can be routed
32/// correctly. Growing primaries (e.g. an `ExtendableSlab`) cannot be
33/// used here — split them out at the application level instead.
34///
35/// # Safety / contract
36///
37/// - If `secondary.contains(ptr)` would also return `true` for a primary-issued
38///   pointer (i.e. their address ranges overlap), deallocation routing is
39///   incorrect and behavior is UB. In practice the secondary is usually
40///   [`crate::backing::System`](https://docs.rs/forge-alloc) which doesn't implement
41///   `FixedRange`, so this concern is hypothetical.
42/// - Calling `deallocate` with a pointer that belongs to neither allocator
43///   is UB in release builds; debug builds gain a tracking check only when
44///   `Statistics` is composed.
45///
46/// # Watermark composition note
47///
48/// `capacity_bytes()` reports the **primary's** capacity only — the secondary
49/// is treated as unbounded overflow drainage, not as part of the budget.
50/// `Watermark<WithFallback<P, S>>` therefore monitors pressure on the FAST
51/// path; secondary-side activity does not move the watermark thresholds.
52/// This is intentional (you want to know when the bump arena is hot, not
53/// when the System heap took a small extra request), but callers who want
54/// total-bytes monitoring should wrap each half separately, e.g.
55/// `WithFallback<Watermark<P, _>, Watermark<S, _>>`.
56///
57/// # API-misuse compile-failures (pinned)
58///
59/// The `Allocator` and `Deallocator` impls require `P: FixedRange` so
60/// `deallocate` can route by pointer-provenance. Using a non-`FixedRange`
61/// primary (e.g. `crate::backing::System`) compiles fine at the `new()`
62/// constructor — `WithFallback` itself imposes no bound — but the
63/// resulting value cannot satisfy the trait bound on `allocate`:
64///
65/// ```compile_fail
66/// // FAILS TO COMPILE: `System` is not `FixedRange`, so
67/// // `WithFallback<System, InlineBacked<256>>` does not implement
68/// // `Allocator` and `.allocate(...)` is not a method on it.
69/// use forge_alloc::{InlineBacked, System};
70/// use forge_alloc::{Allocator, NonZeroLayout};
71/// use forge_alloc::WithFallback;
72/// let wf = WithFallback::new(System, InlineBacked::<256>::new());
73/// let layout = NonZeroLayout::from_size_align(64, 8).unwrap();
74/// let _ = wf.allocate(layout);
75/// ```
76#[derive(Debug)]
77pub struct WithFallback<P, S> {
78    primary: P,
79    secondary: S,
80}
81
82impl<P, S> WithFallback<P, S> {
83    /// Construct from existing primary and secondary instances.
84    ///
85    /// This is the const-fn path; no runtime range check is performed.
86    /// Use this when the secondary doesn't implement
87    /// [`FixedRange`](forge_alloc_core::FixedRange) (the common case — the
88    /// canonical secondary is [`crate::backing::System`](https://docs.rs/forge-alloc),
89    /// which is not `FixedRange`). For that wiring, deallocation
90    /// routing is unambiguous: any pointer outside the primary's
91    /// range goes to the secondary, and `System` accepts any pointer.
92    ///
93    /// **When BOTH halves implement `FixedRange`**, prefer
94    /// [`try_new`](Self::try_new) instead — `try_new` verifies the
95    /// two address ranges are disjoint at construction. Overlapping
96    /// ranges with this constructor will silently misroute
97    /// secondary-issued pointers through the primary's
98    /// `deallocate`, producing a freelist corruption that is hard
99    /// to diagnose after the fact.
100    #[inline]
101    pub const fn new(primary: P, secondary: S) -> Self {
102        Self { primary, secondary }
103    }
104
105    /// Borrow the primary allocator.
106    #[inline]
107    pub fn primary(&self) -> &P {
108        &self.primary
109    }
110
111    /// Borrow the secondary allocator.
112    #[inline]
113    pub fn secondary(&self) -> &S {
114        &self.secondary
115    }
116
117    /// Decompose into the two halves.
118    #[inline]
119    pub fn into_parts(self) -> (P, S) {
120        (self.primary, self.secondary)
121    }
122}
123
124impl<P: FixedRange, S: FixedRange> WithFallback<P, S> {
125    /// Construct with a runtime check that the primary and secondary
126    /// address ranges are disjoint. Returns `Err(AllocError)` on
127    /// overlap — overlapping ranges silently misroute deallocations
128    /// through `deallocate`'s primary-first `contains` test, producing
129    /// a freelist corruption that's hard to debug after the fact.
130    ///
131    /// Use this constructor whenever both halves implement
132    /// `FixedRange`. The default secondary
133    /// [`crate::backing::System`](https://docs.rs/forge-alloc) does not
134    /// implement `FixedRange`, so callers wiring `System` as the
135    /// fallback continue to use [`WithFallback::new`] — `System`
136    /// accepts any pointer, so routing is unambiguous there.
137    ///
138    /// See [`WithFallback::ranges_disjoint`] for the exact check.
139    #[inline]
140    pub fn try_new(primary: P, secondary: S) -> Result<Self, AllocError> {
141        let wf = Self { primary, secondary };
142        if wf.ranges_disjoint() {
143            Ok(wf)
144        } else {
145            Err(AllocError)
146        }
147    }
148
149    /// Return `true` if the primary and secondary address ranges are
150    /// disjoint, i.e. no pointer can belong to both.
151    ///
152    /// This is the runtime check behind the type's safety contract: if
153    /// the ranges overlap, `deallocate`'s primary-first routing test can
154    /// misroute a secondary-issued pointer to `primary.deallocate`,
155    /// which then translates it to a slot index via `slot_index` and
156    /// pushes a fabricated index onto the freelist — silent corruption.
157    ///
158    /// Call once at construction in a `debug_assert!`:
159    ///
160    /// ```ignore
161    /// let wf = WithFallback::new(primary, secondary);
162    /// debug_assert!(wf.ranges_disjoint(), "primary and secondary ranges overlap");
163    /// ```
164    ///
165    /// The default secondary `crate::backing::System` does not implement
166    /// `FixedRange`, so this method is unavailable for that common case;
167    /// `System` accepts any pointer and routing is unambiguous there.
168    #[inline]
169    pub fn ranges_disjoint(&self) -> bool {
170        let p_start = self.primary.base().as_ptr() as usize;
171        let s_start = self.secondary.base().as_ptr() as usize;
172        // `checked_add` rejects either range wrapping past `usize::MAX`.
173        // A wrap would split the range into [start, MAX] U [0, end) and the
174        // comparison below would silently misreport overlap on small `no_std`
175        // targets. Treat a wrap conservatively as "potentially overlapping"
176        // and return `false`.
177        let Some(p_end) = p_start.checked_add(self.primary.size()) else {
178            return false;
179        };
180        let Some(s_end) = s_start.checked_add(self.secondary.size()) else {
181            return false;
182        };
183        // Disjoint iff one range ends at or before the other begins.
184        p_end <= s_start || s_end <= p_start
185    }
186}
187
188unsafe impl<P, S> Deallocator for WithFallback<P, S>
189where
190    P: Allocator + FixedRange,
191    S: Allocator,
192{
193    #[inline]
194    unsafe fn deallocate(&self, ptr: NonNull<u8>, layout: NonZeroLayout) {
195        if self.primary.contains(ptr) {
196            // SAFETY: ptr lies inside primary's range; caller's contract
197            // says it came from this allocator's allocate(), so it must
198            // have come from the primary's allocate().
199            unsafe { self.primary.deallocate(ptr, layout) }
200        } else {
201            // ptr is outside primary's range. In a properly-used router
202            // this means it came from the secondary path. A *foreign*
203            // pointer (one issued by some third allocator that happens
204            // to fall outside primary's range) would be silently routed
205            // to secondary.deallocate, which is UB. The safety contract
206            // on this trait method (and on `Deallocator` generally)
207            // already forbids foreign pointers, so this is the caller's
208            // problem; but a debug-build assertion makes the failure
209            // mode loud during dev. When the secondary is also a
210            // FixedRange (rare — `System` is not), we can validate that
211            // ptr lies inside it; otherwise the assertion is a no-op.
212            //
213            // We don't have a way to dispatch on `S: FixedRange` here at
214            // trait-method level without specialization. The assertion
215            // therefore relies on a runtime ptr-bound test that's
216            // available only when S exposes a FixedRange. Today this is
217            // documented but not statically checked — see the safety
218            // section on the type.
219            //
220            // SAFETY: contract on Deallocator::deallocate gives us a
221            // ptr that came from *this* WithFallback. Since ptr is not
222            // in primary, it must have come from secondary.
223            unsafe { self.secondary.deallocate(ptr, layout) }
224        }
225    }
226}
227
228impl<P, S> WithFallback<P, S>
229where
230    P: Allocator + FixedRange,
231    S: Allocator,
232{
233    /// Out-of-line fallback path. Split out of `allocate` so the hot
234    /// success branch stays compact in the i-cache; the spilling-into-
235    /// secondary case is a cold event by design (primary exhausted) and
236    /// pays a normal function-call cost rather than inlining its
237    /// secondary-allocate body into every `allocate` call site.
238    #[cold]
239    #[inline(never)]
240    fn fallback_allocate(&self, layout: NonZeroLayout) -> Result<NonNull<[u8]>, AllocError> {
241        self.secondary.allocate(layout)
242    }
243
244    #[cold]
245    #[inline(never)]
246    fn fallback_allocate_zeroed(&self, layout: NonZeroLayout) -> Result<NonNull<[u8]>, AllocError> {
247        self.secondary.allocate_zeroed(layout)
248    }
249}
250
251unsafe impl<P, S> Allocator for WithFallback<P, S>
252where
253    P: Allocator + FixedRange,
254    S: Allocator,
255{
256    #[inline]
257    fn allocate(&self, layout: NonZeroLayout) -> Result<NonNull<[u8]>, AllocError> {
258        match self.primary.allocate(layout) {
259            Ok(block) => Ok(block),
260            Err(_) => self.fallback_allocate(layout),
261        }
262    }
263
264    #[inline]
265    fn allocate_zeroed(&self, layout: NonZeroLayout) -> Result<NonNull<[u8]>, AllocError> {
266        match self.primary.allocate_zeroed(layout) {
267            Ok(block) => Ok(block),
268            Err(_) => self.fallback_allocate_zeroed(layout),
269        }
270    }
271
272    #[inline]
273    unsafe fn usable_size(&self, ptr: NonNull<u8>, layout: NonZeroLayout) -> Option<usize> {
274        // Route by provenance, exactly like `deallocate`: a primary-owned
275        // pointer's usable size comes from the primary, otherwise the
276        // secondary. Without this the wrapper would report `None` and an outer
277        // scrub wrapper would miss the slack of whichever half served the
278        // block.
279        if self.primary.contains(ptr) {
280            // SAFETY: ptr lies in primary's range → it came from primary.
281            unsafe { self.primary.usable_size(ptr, layout) }
282        } else {
283            // SAFETY: ptr is outside primary → it came from the secondary.
284            unsafe { self.secondary.usable_size(ptr, layout) }
285        }
286    }
287
288    #[inline]
289    fn capacity_bytes(&self) -> Option<usize> {
290        // Total isn't meaningfully defined here — the secondary may be
291        // unbounded. Report just the primary's capacity for the watermark
292        // model (the secondary is overflow drainage, not budget).
293        self.primary.capacity_bytes()
294    }
295
296    #[inline]
297    fn corruption_events(&self) -> u64 {
298        // Both halves can independently detect corruption. Sum them
299        // — total events across the composed allocator. `saturating_add`
300        // guards against the (extremely unlikely) u64 overflow when
301        // both halves have observed astronomical attack volumes.
302        self.primary
303            .corruption_events()
304            .saturating_add(self.secondary.corruption_events())
305    }
306}
307
308// Gated on `feature = "std"` because the suite uses `System`, which
309// is std-only. The no_std-friendly `InlineBacked` is itself covered
310// by its own test module.
311#[cfg(all(test, feature = "std"))]
312mod tests {
313    use super::*;
314    use crate::backing::{InlineBacked, System};
315
316    /// Helper: WithFallback<InlineBacked<256>, System> built without going
317    /// through BumpArena. InlineBacked is itself a FixedRange Allocator.
318    fn build() -> WithFallback<InlineBacked<256>, System> {
319        WithFallback::new(InlineBacked::<256>::new(), System)
320    }
321
322    #[test]
323    fn primary_used_when_available() {
324        let wf = build();
325        let layout = NonZeroLayout::from_size_align(64, 8).unwrap();
326        let block = wf.allocate(layout).unwrap();
327        let ptr = block.cast::<u8>();
328        assert!(
329            wf.primary().contains(ptr),
330            "primary should serve when it can"
331        );
332    }
333
334    #[test]
335    fn falls_through_when_primary_exhausted() {
336        let wf = build();
337        // Consume primary fully.
338        let big = NonZeroLayout::from_size_align(256, 1).unwrap();
339        let _ = wf.allocate(big).unwrap();
340        // Next allocation must come from secondary.
341        let small = NonZeroLayout::from_size_align(8, 8).unwrap();
342        let block = wf.allocate(small).unwrap();
343        let ptr = block.cast::<u8>();
344        assert!(
345            !wf.primary().contains(ptr),
346            "secondary should serve when primary is exhausted"
347        );
348        // Clean up the secondary allocation.
349        unsafe { wf.deallocate(ptr, small) };
350    }
351
352    #[test]
353    fn deallocate_routes_by_provenance() {
354        let wf = build();
355        let small = NonZeroLayout::from_size_align(8, 8).unwrap();
356
357        // Two allocations: primary then secondary.
358        let big = NonZeroLayout::from_size_align(256, 1).unwrap();
359        let prim = wf.allocate(big).unwrap();
360        let sec = wf.allocate(small).unwrap();
361
362        // Both deallocate via the same call site — router picks the right one.
363        unsafe {
364            wf.deallocate(prim.cast(), big); // primary path (no-op)
365            wf.deallocate(sec.cast(), small); // secondary path (frees heap)
366        }
367    }
368
369    #[test]
370    fn capacity_bytes_reports_primary() {
371        let wf = build();
372        assert_eq!(wf.capacity_bytes(), Some(256));
373    }
374
375    /// `try_new` accepts two FixedRange halves whose address ranges
376    /// don't overlap (the common case — independent backing regions).
377    #[test]
378    fn try_new_accepts_disjoint_fixed_ranges() {
379        // Two independent InlineBacked instances have separate
380        // address ranges (they're each their own stack-allocated
381        // array).
382        let a = InlineBacked::<256>::new();
383        let b = InlineBacked::<256>::new();
384        assert!(WithFallback::try_new(a, b).is_ok());
385    }
386
387    /// Boundary: `ranges_disjoint` reports `true` for two independent
388    /// `InlineBacked<N>` instances sitting next to each other on the
389    /// stack. Each is its own backing region with its own `[base, base+N)`
390    /// extent — adjacent but not overlapping. Pinning the boundary here
391    /// guards against an off-by-one in the `p_end <= s_start ||
392    /// s_end <= p_start` check (using `<` would spuriously flag adjacent
393    /// regions as overlapping).
394    #[test]
395    fn ranges_disjoint_treats_adjacent_regions_as_disjoint() {
396        // Two stack-allocated InlineBackeds. Their addresses depend on
397        // layout but on most ABIs they end up adjacent within the same
398        // frame.
399        let a = InlineBacked::<128>::new();
400        let b = InlineBacked::<128>::new();
401        let wf = WithFallback::new(a, b);
402        assert!(
403            wf.ranges_disjoint(),
404            "independent InlineBacked instances must be disjoint",
405        );
406    }
407
408    /// Watermark composition: `capacity_bytes` reports the primary's
409    /// capacity only, treating the secondary as overflow. Verify the
410    /// promise — adding a System secondary does NOT bump the reported
411    /// capacity.
412    #[test]
413    fn capacity_bytes_reports_only_primary_even_with_system_secondary() {
414        let wf = WithFallback::new(InlineBacked::<256>::new(), System);
415        assert_eq!(wf.capacity_bytes(), Some(256));
416    }
417}