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}