Skip to main content

oxideav_core/arena/
mod.rs

1//! Refcounted arena pool for decoder frame allocations.
2//!
3//! This module is the runtime half of the DoS-protection framework
4//! described in [`crate::limits`]. It provides three types:
5//!
6//! - [`ArenaPool`] — a pool of reusable raw byte buffers (allocated
7//!   via [`std::alloc::alloc`] with a fixed [`MAX_ALIGN`] alignment so
8//!   each buffer's base pointer is suitable for any `T` whose
9//!   alignment is `<= MAX_ALIGN`) that a decoder leases from. Pool
10//!   size and per-buffer capacity are fixed at construction; together
11//!   they bound peak RSS by construction
12//!   (`max_arenas × cap_per_arena`).
13//!
14//! - [`Arena`] — a single buffer leased from the pool. Allocations are
15//!   bump-pointer (no per-alloc bookkeeping, no fragmentation). When
16//!   the `Arena` is dropped, its buffer is returned to the pool, *not*
17//!   freed — this is what makes the pool memory-reusing rather than
18//!   memory-leaking. If the pool has been dropped before the arena
19//!   (last-arena-outlives-pool), the arena's buffer is freed normally.
20//!
21//! - [`Frame`] / [`FrameInner`] — a refcounted (`Rc<FrameInner>`)
22//!   handle that holds an `Arena` plus per-plane offset/length pairs
23//!   and a small [`FrameHeader`]. As long as any clone of a `Frame`
24//!   exists, its arena (and therefore its buffer) stays out of the
25//!   pool. The last `Drop` returns the buffer.
26//!
27//! ## Design choices for round 1
28//!
29//! - **Hand-rolled bump allocator** over a raw `NonNull<u8>` from
30//!   [`std::alloc::alloc`]. We deliberately do not depend on the
31//!   `bumpalo` crate yet — the logic is twenty lines and avoids
32//!   pulling in a dependency before profiling justifies it. The
33//!   signature is intentionally compatible with what a
34//!   `bumpalo`-backed implementation would look like, so swapping
35//!   later is a contained refactor.
36//!
37//! - **`Rc` for `Frame`, not `Arc`.** This module targets the
38//!   single-threaded decode path (one decoder, one consumer thread).
39//!   The bump-pointer cursor is `Cell<usize>` for the same reason
40//!   (no atomics on the hot path). For the cross-thread decode path
41//!   — where a decoder produces frames on one thread and a consumer
42//!   reads them on another — see the sibling [`sync`] module, which
43//!   mirrors this API 1:1 with `Arc<FrameInner>` / atomic cursor so
44//!   `Frame: Send + Sync`.
45//!
46//! - **`Arena::alloc<T>` returns `&mut [T]` borrowed from the arena.**
47//!   The borrow is bounded by the lifetime of the `&Arena` reference,
48//!   not the lifetime of the arena itself; the arena holds the
49//!   buffer's base address as a raw [`NonNull<u8>`] so multiple
50//!   calls to `alloc` against the same `&Arena` can each carve out
51//!   non-overlapping sub-slices without ever materialising a
52//!   whole-buffer mutable borrow (which would invalidate previously
53//!   returned slices under stacked borrows). This matches
54//!   `bumpalo::Bump::alloc_slice_*` semantics.
55//!
56//! ## Soundness notes
57//!
58//! Three issues called out by an external Miri audit (PR #12, May
59//! 2026) shaped the current implementation; they are noted here so
60//! future refactors don't reintroduce them:
61//!
62//! 1. **Base-pointer alignment.** A `Box<[u8]>` is byte-aligned only,
63//!    so even an empty `&mut [u32]` carved out of one would have an
64//!    unaligned pointer (UB). Each pool buffer is now allocated
65//!    directly via [`std::alloc::alloc`] with [`MAX_ALIGN`] (= 64 B,
66//!    enough for AVX-512), so the base pointer is suitable for any
67//!    type the arena will hand out. `alloc::<T>` rejects types whose
68//!    alignment exceeds [`MAX_ALIGN`] at compile time via a
69//!    `const`-evaluated assertion.
70//!
71//! 2. **Invalid bit patterns.** Pool buffers are zero-filled, but
72//!    zero is not a valid bit pattern for every `Copy` type
73//!    (`NonZeroU8`, references, function pointers, niche-optimised
74//!    enums, …). `alloc<T>` is therefore bounded on
75//!    `bytemuck::Zeroable` rather than just `Copy`, so the safe API
76//!    cannot hand out `&mut [NonZeroU8]` over zero bytes.
77//!
78//! 3. **Stacked-borrows retag.** Each `alloc` previously took
79//!    `[u8]::as_mut_ptr` of the whole backing slice, which retagged
80//!    the whole buffer and popped the borrow stacks of every
81//!    previously returned `&mut [T]`. The fix is the raw `NonNull<u8>`
82//!    base pointer above: each `alloc` does
83//!    `base.as_ptr().add(offset).cast::<T>()` and never re-borrows
84//!    the whole buffer.
85
86pub mod sync;
87
88use std::alloc::{alloc_zeroed, dealloc, Layout};
89use std::cell::Cell;
90use std::mem::{align_of, size_of};
91use std::ptr::{self, NonNull};
92use std::rc::Rc;
93use std::sync::{Arc, Mutex, Weak};
94
95use crate::error::{Error, Result};
96use crate::format::PixelFormat;
97
98/// Alignment used for every pool buffer's base pointer. 64 bytes
99/// covers the alignment requirements of every primitive type and of
100/// AVX-512 SIMD loads (`__m512` is 64-byte aligned). [`Arena::alloc`]
101/// statically rejects any type with a stricter alignment requirement.
102pub(crate) const MAX_ALIGN: usize = 64;
103
104/// Strict-provenance-compatible `MAX_ALIGN`-aligned dangling sentinel
105/// used for the `cap == 0` empty-buffer case in [`Buffer::new_zeroed`].
106///
107/// Casting a bare integer to `*mut u8` is rejected by Miri's
108/// `-Zmiri-strict-provenance` check (the resulting pointer has no
109/// provenance and cannot legally be reborrowed). Taking the address of
110/// a real static gives us a properly-provenanced pointer with the same
111/// runtime properties (non-null, `MAX_ALIGN`-aligned, never
112/// dereferenced for `cap == 0`).
113///
114/// `#[repr(align(N))]` requires a literal, so the `const_assert` below
115/// (a const-eval'd `assert!`) catches the day someone bumps
116/// [`MAX_ALIGN`] without updating the literal here.
117#[repr(align(64))]
118struct AlignedSentinel([u8; 0]);
119
120const _: () = assert!(
121    align_of::<AlignedSentinel>() == MAX_ALIGN,
122    "AlignedSentinel alignment must match MAX_ALIGN; \
123     update the #[repr(align(N))] literal on AlignedSentinel"
124);
125
126static EMPTY_SENTINEL: AlignedSentinel = AlignedSentinel([]);
127
128/// Layout used to allocate (and deallocate) pool buffers. `cap` is the
129/// per-arena byte capacity; alignment is fixed at [`MAX_ALIGN`].
130///
131/// Returns `None` for `cap == 0` — `Layout::from_size_align` rejects
132/// zero-sized layouts and we can't pass a zero-sized layout to
133/// `std::alloc::alloc`. Callers must special-case the empty arena.
134pub(crate) fn buffer_layout(cap: usize) -> Option<Layout> {
135    if cap == 0 {
136        None
137    } else {
138        Layout::from_size_align(cap, MAX_ALIGN).ok()
139    }
140}
141
142/// Backing storage for one pool buffer — a raw aligned byte buffer
143/// produced by [`std::alloc::alloc_zeroed`] (or a sentinel for the
144/// `cap == 0` case, which doesn't allocate). Owns the allocation;
145/// frees it in `Drop`. Used by both [`crate::arena::ArenaPool`] and
146/// [`crate::arena::sync::ArenaPool`].
147pub(crate) struct Buffer {
148    /// Base pointer. For `cap > 0` this points at a live allocation
149    /// of `cap` bytes aligned to [`MAX_ALIGN`]. For `cap == 0` this is
150    /// a [`MAX_ALIGN`]-aligned dangling pointer (no backing storage).
151    pub(crate) ptr: NonNull<u8>,
152    /// Capacity of the allocation in bytes (also the layout `size`).
153    pub(crate) cap: usize,
154}
155
156// SAFETY: `Buffer` owns its allocation outright (no aliasing) and
157// `NonNull<u8>` is `!Send + !Sync` only out of caution; sending the
158// owning handle to another thread is sound.
159unsafe impl Send for Buffer {}
160unsafe impl Sync for Buffer {}
161
162impl Buffer {
163    /// Allocate a buffer of `cap` bytes aligned to [`MAX_ALIGN`],
164    /// zero-filled. For `cap == 0` returns a dangling-but-aligned
165    /// sentinel (matching `NonNull::dangling()` semantics for an
166    /// arbitrary-alignment pointer) without touching the global
167    /// allocator.
168    pub(crate) fn new_zeroed(cap: usize) -> Self {
169        match buffer_layout(cap) {
170            None => {
171                // Produce a `MAX_ALIGN`-aligned dangling pointer that
172                // is never dereferenced (cap == 0 means no allocation
173                // accesses go through it). We use the address of a
174                // real `MAX_ALIGN`-aligned static rather than an
175                // integer-to-pointer cast: the latter is rejected by
176                // Miri's `-Zmiri-strict-provenance` check (the
177                // resulting pointer has no provenance and cannot
178                // legally be reborrowed). `NonNull::from(&...)`
179                // preserves provenance and is strict-provenance
180                // friendly; the `cast::<u8>()` is also
181                // provenance-preserving.
182                Buffer {
183                    ptr: NonNull::from(&EMPTY_SENTINEL).cast::<u8>(),
184                    cap: 0,
185                }
186            }
187            Some(layout) => {
188                // SAFETY: layout has non-zero size (we just checked).
189                let raw = unsafe { alloc_zeroed(layout) };
190                let ptr =
191                    NonNull::new(raw).unwrap_or_else(|| std::alloc::handle_alloc_error(layout));
192                Buffer { ptr, cap }
193            }
194        }
195    }
196
197    /// Zero the entire buffer. Called when a buffer is returned to the
198    /// pool so a subsequent lease starts from a clean (and therefore
199    /// `Zeroable`-valid) state.
200    pub(crate) fn zero(&mut self) {
201        if self.cap > 0 {
202            // SAFETY: ptr points to `cap` bytes of writable storage we
203            // own exclusively (`&mut self`).
204            unsafe { ptr::write_bytes(self.ptr.as_ptr(), 0, self.cap) };
205        }
206    }
207}
208
209impl Drop for Buffer {
210    fn drop(&mut self) {
211        if let Some(layout) = buffer_layout(self.cap) {
212            // SAFETY: ptr was returned by `alloc_zeroed(layout)` and
213            // we have not freed it yet.
214            unsafe { dealloc(self.ptr.as_ptr(), layout) };
215        }
216    }
217}
218
219/// Pool of reusable byte buffers for arena-backed frame allocations.
220///
221/// Construct one per decoder via [`ArenaPool::new`]. Lease an
222/// [`Arena`] per frame via [`ArenaPool::lease`]; drop the arena (or
223/// drop the last clone of a [`Frame`] holding it) to return its
224/// buffer to the pool.
225///
226/// **Backpressure:** when all `max_arenas` slots are checked out the
227/// next [`ArenaPool::lease`] returns
228/// [`Error::ResourceExhausted`]. A decoder that hits this should
229/// surface the error to its caller rather than busy-loop — the
230/// upstream pipeline is supposed to drop frames it no longer needs,
231/// which returns a buffer to the pool.
232///
233/// `ArenaPool` is `Send + Sync` (the inner `Mutex<Vec<…>>` makes it
234/// safe to share across threads even though [`Arena`] / [`Frame`]
235/// themselves are `!Send` due to their `Rc`/`Cell` contents). This
236/// asymmetry is intentional: a parallel-decoder thread can share a
237/// single pool while each thread owns its own arenas — see also the
238/// sibling [`sync::ArenaPool`] whose leases are themselves `Send + Sync`.
239pub struct ArenaPool {
240    inner: Mutex<PoolInner>,
241    cap_per_arena: usize,
242    max_arenas: usize,
243    max_alloc_count_per_arena: u32,
244}
245
246struct PoolInner {
247    /// Buffers currently sitting idle in the pool (ready to lease).
248    idle: Vec<Buffer>,
249    /// Total buffers ever allocated by this pool (idle + in-flight).
250    /// Caps lazy growth at `max_arenas`.
251    total_allocated: usize,
252}
253
254impl ArenaPool {
255    /// Construct a new pool with `max_arenas` buffer slots, each of
256    /// `cap_per_arena` bytes. Buffers are allocated lazily on first
257    /// lease — a freshly constructed pool holds no memory.
258    ///
259    /// Per-arena allocation count is capped at `max_alloc_count` (use
260    /// [`ArenaPool::new`] which defaults to a generous 1M, or
261    /// [`ArenaPool::with_alloc_count_cap`] to tighten further).
262    pub fn new(max_arenas: usize, cap_per_arena: usize) -> Arc<Self> {
263        Self::with_alloc_count_cap(max_arenas, cap_per_arena, 1_000_000)
264    }
265
266    /// Like [`ArenaPool::new`] but lets the caller set the per-arena
267    /// allocation-count cap. Useful when the caller is plumbing
268    /// [`crate::DecoderLimits`] through.
269    pub fn with_alloc_count_cap(
270        max_arenas: usize,
271        cap_per_arena: usize,
272        max_alloc_count_per_arena: u32,
273    ) -> Arc<Self> {
274        Arc::new(Self {
275            inner: Mutex::new(PoolInner {
276                idle: Vec::with_capacity(max_arenas),
277                total_allocated: 0,
278            }),
279            cap_per_arena,
280            max_arenas,
281            max_alloc_count_per_arena,
282        })
283    }
284
285    /// Capacity of each arena buffer this pool hands out, in bytes.
286    pub fn cap_per_arena(&self) -> usize {
287        self.cap_per_arena
288    }
289
290    /// Maximum number of arenas that may be checked out at once.
291    pub fn max_arenas(&self) -> usize {
292        self.max_arenas
293    }
294
295    /// Lease one arena from the pool. Returns
296    /// [`Error::ResourceExhausted`] if every arena slot is already
297    /// checked out by an [`Arena`] (or a [`Frame`] holding one).
298    pub fn lease(self: &Arc<Self>) -> Result<Arena> {
299        let buffer = {
300            let mut inner = self.inner.lock().expect("ArenaPool mutex poisoned");
301            if let Some(buf) = inner.idle.pop() {
302                buf
303            } else if inner.total_allocated < self.max_arenas {
304                inner.total_allocated += 1;
305                Buffer::new_zeroed(self.cap_per_arena)
306            } else {
307                return Err(Error::resource_exhausted(format!(
308                    "ArenaPool exhausted: all {} arenas checked out",
309                    self.max_arenas
310                )));
311            }
312        };
313
314        let base = buffer.ptr;
315        Ok(Arena {
316            buffer: Cell::new(Some(buffer)),
317            base,
318            cursor: Cell::new(0),
319            alloc_count: Cell::new(0),
320            cap: self.cap_per_arena,
321            alloc_count_cap: self.max_alloc_count_per_arena,
322            pool: Arc::downgrade(self),
323        })
324    }
325
326    /// Return a buffer to the idle list. Called from `Arena::Drop`;
327    /// not part of the public API. The buffer is zeroed before being
328    /// returned so the next lease starts from a clean state — this is
329    /// what makes `Zeroable` a sufficient bound on `Arena::alloc<T>`
330    /// across pool reuse cycles.
331    fn release(&self, mut buffer: Buffer) {
332        buffer.zero();
333        if let Ok(mut inner) = self.inner.lock() {
334            inner.idle.push(buffer);
335        }
336        // If the lock is poisoned, drop the buffer normally — the
337        // pool is in an unusable state already.
338    }
339}
340
341/// One leased buffer from an [`ArenaPool`].
342///
343/// Allocations are bump-pointer: each call to [`Arena::alloc`] carves
344/// out a fresh aligned slice from the head of the buffer. There is no
345/// per-allocation header and no individual free — the entire arena
346/// is reset (returned to the pool) only when the `Arena` is dropped.
347///
348/// `Arena` is `!Send + !Sync` because its bump cursor is a `Cell` and
349/// its buffer cell is `Cell<Option<Buffer>>` (not synchronised). This
350/// is fine for the round-1 single-threaded decoder path. The sibling
351/// [`sync::Arena`] uses `AtomicUsize` for the cursor and a `Mutex`
352/// around the buffer slot to regain `Send + Sync`.
353pub struct Arena {
354    /// Backing buffer leased from the pool. `Cell<Option<Buffer>>` so
355    /// `Drop` can `take()` the buffer and hand it back to the pool
356    /// without needing `&mut self`. Outside of `Drop` this is always
357    /// `Some`.
358    ///
359    /// We never re-borrow this buffer mutably while handing out
360    /// slices from it — the typed pointers returned by `alloc` are
361    /// derived from the cached raw `base` pointer below, never from
362    /// `(*buffer).as_mut_ptr()`. This avoids the stacked-borrows
363    /// "whole-buffer retag invalidates previously returned slices"
364    /// problem.
365    buffer: Cell<Option<Buffer>>,
366    /// Cached base pointer of `buffer` (a [`MAX_ALIGN`]-aligned
367    /// allocation owned by `buffer`). Stable for the lifetime of the
368    /// arena: `Buffer` does not move its allocation, and we only take
369    /// `buffer` out of the cell during `Drop` after no allocator
370    /// activity remains. All `alloc` calls derive their typed
371    /// pointers from `base.as_ptr().add(offset)`.
372    base: NonNull<u8>,
373    /// Bump cursor: the next free byte offset within the buffer.
374    cursor: Cell<usize>,
375    /// Number of allocations performed so far.
376    alloc_count: Cell<u32>,
377    /// Cached cap (== `pool.cap_per_arena` at lease time).
378    cap: usize,
379    /// Cached cap (== `pool.max_alloc_count_per_arena` at lease time).
380    alloc_count_cap: u32,
381    /// Weak handle back to the pool so `Drop` can return the buffer.
382    pool: Weak<ArenaPool>,
383}
384
385impl Arena {
386    /// Capacity of this arena in bytes.
387    pub fn capacity(&self) -> usize {
388        self.cap
389    }
390
391    /// Bytes consumed by allocations so far.
392    pub fn used(&self) -> usize {
393        self.cursor.get()
394    }
395
396    /// Number of allocations performed so far.
397    pub fn alloc_count(&self) -> u32 {
398        self.alloc_count.get()
399    }
400
401    /// `true` once the per-arena allocation-count cap has been
402    /// reached. Decoders that produce many small allocations should
403    /// poll this and bail with [`Error::ResourceExhausted`] when it
404    /// flips, instead of waiting for the next [`Arena::alloc`] call
405    /// to fail.
406    pub fn alloc_count_exceeded(&self) -> bool {
407        self.alloc_count.get() >= self.alloc_count_cap
408    }
409
410    /// Allocate `count` `T`s out of this arena. Returns a borrowed
411    /// `&mut [T]` (lifetime bounded by the borrow of `self`).
412    ///
413    /// The returned slice points at zero-filled bytes (the pool
414    /// zero-fills on initial allocation and again whenever a buffer
415    /// is returned). The `Zeroable` bound on `T` guarantees that an
416    /// all-zero bit pattern is a valid value for `T`, so reading the
417    /// slice without first writing it is sound. **The intended
418    /// pattern is still "decoder fills the slice, then reads back
419    /// what it wrote" — but unwritten bytes will read back as
420    /// `T::zeroed()` rather than as UB.**
421    ///
422    /// Returns [`Error::ResourceExhausted`] if either the per-arena
423    /// byte cap or the per-arena allocation-count cap would be
424    /// exceeded.
425    ///
426    /// # Type bounds
427    ///
428    /// - `T: bytemuck::Zeroable` — pool buffers are zero-filled, so
429    ///   handing back `&mut [T]` over those bytes is only sound when
430    ///   the all-zero bit pattern is valid for `T`. This rules out
431    ///   `NonZeroU8`/`NonZeroU16`/…/references/function pointers/
432    ///   niche-optimised enums (anything where the optimizer relies
433    ///   on a forbidden-bit-pattern invariant).
434    /// - `align_of::<T>() <= MAX_ALIGN` — checked at compile time via
435    ///   a `const` assertion. The pool buffer's base pointer is
436    ///   aligned to [`MAX_ALIGN`] (= 64 bytes); per-`T` alignment is
437    ///   then a relative-offset adjustment of the bump cursor.
438    /// - The arena does not run destructors on allocated values, so
439    ///   `T` should not have meaningful `Drop` glue. `Zeroable` is
440    ///   automatically implemented only for types where this is the
441    ///   case (primitives, `[T; N]` of zeroable, `#[derive(Zeroable)]`
442    ///   on POD structs).
443    ///
444    /// **Aliasing model:** the bump cursor is monotonically
445    /// non-decreasing, so successive `alloc` calls return slices
446    /// covering disjoint regions of the underlying buffer. The
447    /// returned typed pointer is derived from the arena's cached raw
448    /// base pointer (`base.as_ptr().add(offset)`), never from a
449    /// re-borrow of the whole buffer — that's what keeps previously
450    /// returned `&mut [T]` slices valid under stacked borrows. This
451    /// is the standard arena-allocator pattern (cf.
452    /// `bumpalo::Bump::alloc_slice_*`) and is the reason this method
453    /// takes `&self` rather than `&mut self`.
454    #[allow(clippy::mut_from_ref)] // see "Aliasing model" doc above.
455    pub fn alloc<T>(&self, count: usize) -> Result<&mut [T]>
456    where
457        T: bytemuck::Zeroable,
458    {
459        // Compile-time check: T's alignment must not exceed the
460        // pool buffer's base alignment. Doing this as a const-eval'd
461        // assert means a violating monomorphisation fails the build.
462        const fn assert_align<T>() {
463            assert!(
464                align_of::<T>() <= MAX_ALIGN,
465                "Arena::alloc<T>: align_of::<T>() exceeds MAX_ALIGN; \
466                 increase MAX_ALIGN in arena/mod.rs"
467            );
468        }
469        const { assert_align::<T>() };
470
471        // Allocation-count cap.
472        let next_count =
473            self.alloc_count.get().checked_add(1).ok_or_else(|| {
474                Error::resource_exhausted("Arena alloc_count overflow".to_string())
475            })?;
476        if next_count > self.alloc_count_cap {
477            return Err(Error::resource_exhausted(format!(
478                "Arena alloc-count cap of {} exceeded",
479                self.alloc_count_cap
480            )));
481        }
482
483        let elem_size = size_of::<T>();
484        let elem_align = align_of::<T>();
485        // Bytes requested.
486        let bytes = elem_size
487            .checked_mul(count)
488            .ok_or_else(|| Error::resource_exhausted("Arena alloc size overflow".to_string()))?;
489
490        // Align cursor up to T's alignment.
491        let cursor = self.cursor.get();
492        let aligned = align_up(cursor, elem_align).ok_or_else(|| {
493            Error::resource_exhausted("Arena cursor alignment overflow".to_string())
494        })?;
495        let new_cursor = aligned.checked_add(bytes).ok_or_else(|| {
496            Error::resource_exhausted("Arena cursor advance overflow".to_string())
497        })?;
498
499        if new_cursor > self.cap {
500            return Err(Error::resource_exhausted(format!(
501                "Arena cap of {} bytes exceeded (would consume {} bytes)",
502                self.cap, new_cursor
503            )));
504        }
505
506        // SAFETY:
507        //
508        // - `self.base` points to a `MAX_ALIGN`-aligned allocation of
509        //   `self.cap` bytes owned by the `Buffer` inside `self.buffer`,
510        //   which lives at least as long as `&self`.
511        // - `aligned + count*size_of::<T>() <= self.cap` (just checked
512        //   above), so the byte range we slice is in-bounds.
513        // - `aligned` is a multiple of `align_of::<T>()` (computed via
514        //   `align_up`), and `MAX_ALIGN >= align_of::<T>()` (compile-
515        //   time assert above), so `base + aligned` is `T`-aligned.
516        //   This holds even for `count == 0` (the slice still has an
517        //   aligned dangling pointer, which is what an empty `&mut [T]`
518        //   requires).
519        // - The cursor is monotonically non-decreasing, so the byte
520        //   range `aligned..new_cursor` does not overlap any byte
521        //   range previously returned by `alloc`. We never re-borrow
522        //   the whole buffer — the typed pointer is derived from the
523        //   raw base pointer — so the new `&mut [T]` does not invalidate
524        //   any previously returned slice under stacked borrows.
525        // - `T: Zeroable` and the buffer bytes are zero, so the
526        //   `&mut [T]` references valid `T` values (the safe API
527        //   contract).
528        let slice: &mut [T] = unsafe {
529            let elem_ptr = self.base.as_ptr().add(aligned).cast::<T>();
530            std::slice::from_raw_parts_mut(elem_ptr, count)
531        };
532
533        self.cursor.set(new_cursor);
534        self.alloc_count.set(next_count);
535        Ok(slice)
536    }
537
538    /// Reset the arena to empty without releasing its buffer to the
539    /// pool. Useful for a decoder that wants to reuse the same arena
540    /// across several intermediate stages of the same frame. Callers
541    /// must ensure no slice previously returned from [`Arena::alloc`]
542    /// is still in use — Rust's borrow checker enforces this, since
543    /// `reset` takes `&mut self`.
544    pub fn reset(&mut self) {
545        self.cursor.set(0);
546        self.alloc_count.set(0);
547    }
548}
549
550impl Drop for Arena {
551    fn drop(&mut self) {
552        // Take the buffer out of the cell. We're in Drop with `&mut
553        // self`, so no `alloc`-returned slices can still be borrowing
554        // from `base`.
555        if let Some(buffer) = self.buffer.take() {
556            if let Some(pool) = self.pool.upgrade() {
557                pool.release(buffer);
558            } else {
559                // Pool was dropped before us — buffer drops here and
560                // its allocation is freed via `Buffer::Drop`.
561                drop(buffer);
562            }
563        }
564    }
565}
566
567/// Round `n` up to the next multiple of `align`. `align` must be a
568/// power of two. Returns `None` on overflow.
569fn align_up(n: usize, align: usize) -> Option<usize> {
570    debug_assert!(align.is_power_of_two(), "alignment must be a power of two");
571    let mask = align - 1;
572    n.checked_add(mask).map(|m| m & !mask)
573}
574
575/// Per-frame metadata carried alongside an [`Arena`] inside a
576/// [`Frame`]. Kept minimal in round 1; round 2 will extend with
577/// stride/colorspace/HDR fields as decoders need them.
578///
579/// `Copy` so it travels through the hot path with no allocation.
580#[non_exhaustive]
581#[derive(Copy, Clone, Debug)]
582pub struct FrameHeader {
583    pub width: u32,
584    pub height: u32,
585    pub pixel_format: PixelFormat,
586    /// Presentation timestamp in stream time-base units. `None` when
587    /// the codec did not surface one (e.g. a still image).
588    pub presentation_timestamp: Option<i64>,
589}
590
591impl FrameHeader {
592    /// Construct a header with all four mandatory fields set. Use
593    /// functional-update syntax (`FrameHeader { ..header }`) to add
594    /// future fields safely.
595    pub fn new(
596        width: u32,
597        height: u32,
598        pixel_format: PixelFormat,
599        presentation_timestamp: Option<i64>,
600    ) -> Self {
601        Self {
602            width,
603            height,
604            pixel_format,
605            presentation_timestamp,
606        }
607    }
608}
609
610/// Maximum number of planes a [`FrameInner`] can describe in round 1.
611/// Covers every real-world video pixel format (1 plane for packed
612/// RGB/YUV 4:2:2, 3 planes for I420/YV12/I444, 4 planes for YUVA / RGBA
613/// planar). Audio is handled by a separate sibling type in a future
614/// round; this module is video-only for now.
615pub const MAX_PLANES: usize = 4;
616
617/// The owned body of a refcounted [`Frame`].
618///
619/// Holds an [`Arena`] (the bytes), a fixed-size table of
620/// `(offset_in_arena, length_in_bytes)` pairs (one per plane), and a
621/// [`FrameHeader`]. The `plane_count` field tracks how many entries of
622/// `plane_offsets` are actually populated. Up to [`MAX_PLANES`] planes
623/// are supported.
624///
625/// **Lifetime:** an `Arena` returns its buffer to the pool when
626/// dropped. A `Rc<FrameInner>` keeps the arena alive via its single
627/// owned field, so as long as any clone of a [`Frame`] exists the
628/// underlying buffer stays out of the pool.
629pub struct FrameInner {
630    arena: Arena,
631    plane_offsets: [(usize, usize); MAX_PLANES],
632    plane_count: u8,
633    header: FrameHeader,
634}
635
636/// Refcounted handle to a decoded video frame. Construct via
637/// [`Frame::new`]; clone freely (each clone bumps the refcount by 1).
638/// The arena and its buffer are released back to the pool when the
639/// last clone is dropped.
640///
641/// `Frame` is `Rc<FrameInner>` (single-threaded decoder path). For the
642/// cross-thread decode path — where the consumer runs on a different
643/// thread from the decoder — use the sibling [`sync::Frame`] which is
644/// `Arc<sync::FrameInner>` and is `Send + Sync`.
645pub type Frame = Rc<FrameInner>;
646
647impl FrameInner {
648    /// Construct a `Frame` (refcounted `Rc<FrameInner>`) from an arena,
649    /// a slice of `(offset, length)` plane descriptors, and a header.
650    /// Returns [`Error::InvalidData`] if more than [`MAX_PLANES`]
651    /// planes are supplied or if any plane range falls outside the
652    /// arena's used region.
653    pub fn new(arena: Arena, planes: &[(usize, usize)], header: FrameHeader) -> Result<Frame> {
654        if planes.len() > MAX_PLANES {
655            return Err(Error::invalid(format!(
656                "FrameInner supports at most {} planes (got {})",
657                MAX_PLANES,
658                planes.len()
659            )));
660        }
661        let used = arena.used();
662        for (i, (off, len)) in planes.iter().enumerate() {
663            let end = off
664                .checked_add(*len)
665                .ok_or_else(|| Error::invalid(format!("plane {i}: offset+len overflow")))?;
666            if end > used {
667                return Err(Error::invalid(format!(
668                    "plane {i}: range {off}..{end} exceeds arena used={used}"
669                )));
670            }
671        }
672        let mut plane_offsets = [(0usize, 0usize); MAX_PLANES];
673        for (i, p) in planes.iter().enumerate() {
674            plane_offsets[i] = *p;
675        }
676        Ok(Rc::new(FrameInner {
677            arena,
678            plane_offsets,
679            plane_count: planes.len() as u8,
680            header,
681        }))
682    }
683
684    /// Number of planes this frame holds.
685    pub fn plane_count(&self) -> usize {
686        self.plane_count as usize
687    }
688
689    /// Read-only access to plane `i`. Returns `None` if `i` is out of
690    /// range.
691    pub fn plane(&self, i: usize) -> Option<&[u8]> {
692        if i >= self.plane_count as usize {
693            return None;
694        }
695        let (off, len) = self.plane_offsets[i];
696        // SAFETY:
697        // - plane ranges were validated against `arena.used()` at
698        //   construction (`off + len <= arena.cursor`), and the
699        //   cursor is monotonically non-decreasing, so the byte
700        //   range is still in-bounds.
701        // - The bytes were written by `alloc` and never moved (the
702        //   buffer's allocation is stable for the arena's lifetime).
703        // - We derive the slice from the raw base pointer, never via
704        //   a re-borrow of the whole buffer, so this `&[u8]` does not
705        //   invalidate any other slice the caller is holding.
706        // - The borrow lifetime is bounded by `&self`.
707        let buf: &[u8] = unsafe {
708            let elem_ptr = self.arena.base.as_ptr().add(off);
709            std::slice::from_raw_parts(elem_ptr, len)
710        };
711        Some(buf)
712    }
713
714    /// Frame header (width / height / pixel format / pts).
715    pub fn header(&self) -> &FrameHeader {
716        &self.header
717    }
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    fn small_pool(slots: usize, cap: usize) -> Arc<ArenaPool> {
725        ArenaPool::new(slots, cap)
726    }
727
728    #[test]
729    fn pool_lease_returns_err_when_exhausted() {
730        let pool = small_pool(2, 1024);
731        let a = pool.lease().expect("first lease");
732        let b = pool.lease().expect("second lease");
733        let third = pool.lease();
734        assert!(matches!(third, Err(Error::ResourceExhausted(_))));
735        // Keep a and b alive past the assertion so they aren't dropped
736        // before the failing lease.
737        drop((a, b));
738    }
739
740    #[test]
741    fn arena_alloc_caps_at_size_limit() {
742        let pool = small_pool(1, 64);
743        let arena = pool.lease().unwrap();
744        // 64 bytes capacity. Allocate 32 u8s — fits.
745        let _: &mut [u8] = arena.alloc::<u8>(32).unwrap();
746        // Allocate another 32 u8s — exactly fills.
747        let _: &mut [u8] = arena.alloc::<u8>(32).unwrap();
748        // Any further allocation fails.
749        let third = arena.alloc::<u8>(1);
750        assert!(matches!(third, Err(Error::ResourceExhausted(_))));
751    }
752
753    #[test]
754    fn arena_alloc_count_cap_fires() {
755        let pool = ArenaPool::with_alloc_count_cap(1, 1024, 3);
756        let arena = pool.lease().unwrap();
757        let _: &mut [u8] = arena.alloc::<u8>(1).unwrap();
758        let _: &mut [u8] = arena.alloc::<u8>(1).unwrap();
759        let _: &mut [u8] = arena.alloc::<u8>(1).unwrap();
760        assert!(arena.alloc_count_exceeded());
761        let fourth = arena.alloc::<u8>(1);
762        assert!(matches!(fourth, Err(Error::ResourceExhausted(_))));
763    }
764
765    #[test]
766    fn arena_returns_to_pool_on_drop() {
767        let pool = small_pool(1, 256);
768        {
769            let arena = pool.lease().expect("first lease");
770            // Sanity: arena is leased; further leases would fail.
771            assert!(matches!(pool.lease(), Err(Error::ResourceExhausted(_))));
772            drop(arena);
773        }
774        // Arena dropped — pool slot must be free again.
775        let _again = pool.lease().expect("re-lease after drop");
776    }
777
778    #[test]
779    fn arena_alignment_is_respected() {
780        let pool = small_pool(1, 64);
781        let arena = pool.lease().unwrap();
782        // Allocate a single u8 to misalign the cursor.
783        let _: &mut [u8] = arena.alloc::<u8>(1).unwrap();
784        // Now allocate u32s; expect cursor to be aligned to 4.
785        let s: &mut [u32] = arena.alloc::<u32>(4).unwrap();
786        let addr = s.as_ptr() as usize;
787        assert_eq!(addr % align_of::<u32>(), 0);
788        assert_eq!(s.len(), 4);
789    }
790
791    #[cfg(miri)]
792    #[test]
793    fn arena_alloc_can_return_misaligned_typed_slice() {
794        let pool = small_pool(1, 0);
795        let arena = pool.lease().unwrap();
796
797        // Memory-safety issue: the arena's backing allocation is a
798        // `Box<[u8]>`, so its base pointer is only guaranteed to be
799        // byte-aligned. `alloc::<T>` aligns only the byte offset, not
800        // the absolute address, and then constructs `&mut [T]`. The
801        // empty-buffer case makes this deterministic: even an empty
802        // `&mut [u32]` must have an aligned pointer, but `Box<[u8]>`
803        // uses an alignment-1 dangling pointer when its length is 0.
804        let _s: &mut [u32] = arena.alloc::<u32>(0).unwrap();
805    }
806
807    // Pre-fix this test was:
808    //
809    //     let values = arena.alloc::<std::num::NonZeroU8>(1).unwrap();
810    //     let _ = values[0].get();
811    //
812    // and failed under Miri because pool buffers are zero-filled and
813    // zero is not a valid `NonZeroU8`. Post-fix, the `Zeroable` bound
814    // on `Arena::alloc` makes that call a hard *compile* error — the
815    // strongest possible enforcement. The test below is a regression
816    // assertion that the bound stays as-or-stricter than `Zeroable`:
817    // if a future refactor weakened it back to `Copy`, the
818    // commented-out call site would start compiling again and Miri
819    // would once again accept the invalid bit pattern.
820    #[cfg(miri)]
821    #[test]
822    fn arena_alloc_allows_invalid_bit_patterns_for_copy_types() {
823        // `requires_zeroable::<NonZeroU8>()` would not compile —
824        // `NonZeroU8: !Zeroable`. Sanity-check the helper itself with
825        // a known zeroable type so the test is an actual exercise.
826        fn requires_zeroable<T: bytemuck::Zeroable>() {}
827        requires_zeroable::<u8>();
828        // Uncommenting the next line must fail to compile:
829        //   requires_zeroable::<std::num::NonZeroU8>();
830    }
831
832    #[cfg(miri)]
833    #[test]
834    fn arena_alloc_second_slice_invalidates_first_mut_reference() {
835        let pool = small_pool(1, 2);
836        let arena = pool.lease().unwrap();
837
838        // Memory-safety issue: each `alloc` calls `[u8]::as_mut_ptr` on
839        // the whole backing slice before carving out the requested
840        // subslice. That materializes a new mutable borrow of the whole
841        // buffer and invalidates previously returned `&mut` slices, even
842        // when the byte ranges are disjoint.
843        let first = arena.alloc::<u8>(1).unwrap();
844        let second = arena.alloc::<u8>(1).unwrap();
845        first[0] = 1;
846        second[0] = 2;
847    }
848
849    fn build_simple_frame(pool: &Arc<ArenaPool>) -> Frame {
850        let arena = pool.lease().unwrap();
851        // Allocate 16 bytes for plane 0.
852        let plane0: &mut [u8] = arena.alloc::<u8>(16).unwrap();
853        for (i, b) in plane0.iter_mut().enumerate() {
854            *b = i as u8;
855        }
856        // The slice borrowed from arena ends here.
857        let header = FrameHeader::new(4, 4, PixelFormat::Gray8, Some(42));
858        FrameInner::new(arena, &[(0, 16)], header).unwrap()
859    }
860
861    #[test]
862    fn frame_refcount_keeps_arena_alive() {
863        let pool = small_pool(1, 256);
864        let frame = build_simple_frame(&pool);
865        let clone = Rc::clone(&frame);
866        drop(frame);
867        // Clone is still valid; arena still leased.
868        let plane = clone.plane(0).expect("plane 0");
869        assert_eq!(plane.len(), 16);
870        for (i, b) in plane.iter().enumerate() {
871            assert_eq!(*b, i as u8);
872        }
873        assert_eq!(clone.header().width, 4);
874        assert_eq!(clone.header().height, 4);
875        assert_eq!(clone.header().presentation_timestamp, Some(42));
876        // Pool still exhausted because clone holds the arena.
877        assert!(matches!(pool.lease(), Err(Error::ResourceExhausted(_))));
878    }
879
880    #[test]
881    fn last_drop_returns_arena_to_pool() {
882        let pool = small_pool(1, 256);
883        let frame = build_simple_frame(&pool);
884        let clone = Rc::clone(&frame);
885        drop(frame);
886        drop(clone);
887        // All clones gone — buffer must be back in the pool.
888        let _again = pool.lease().expect("lease after last drop");
889    }
890
891    #[test]
892    fn frame_rejects_too_many_planes() {
893        let pool = small_pool(1, 256);
894        let arena = pool.lease().unwrap();
895        let header = FrameHeader::new(1, 1, PixelFormat::Gray8, None);
896        let too_many = vec![(0usize, 0usize); MAX_PLANES + 1];
897        let r = FrameInner::new(arena, &too_many, header);
898        assert!(matches!(r, Err(Error::InvalidData(_))));
899    }
900
901    #[test]
902    fn frame_rejects_plane_outside_arena() {
903        let pool = small_pool(1, 64);
904        let arena = pool.lease().unwrap();
905        // arena.used() == 0; any non-empty plane is out of range.
906        let header = FrameHeader::new(1, 1, PixelFormat::Gray8, None);
907        let r = FrameInner::new(arena, &[(0, 16)], header);
908        assert!(matches!(r, Err(Error::InvalidData(_))));
909    }
910
911    #[test]
912    fn pool_outlives_buffer_drop_when_pool_dropped_first() {
913        // Exotic: arena outlives its pool. Buffer just frees normally.
914        let pool = small_pool(1, 64);
915        let arena = pool.lease().unwrap();
916        drop(pool);
917        // Drop arena — must not panic. The Weak handle won't upgrade.
918        drop(arena);
919    }
920
921    #[test]
922    fn arena_reset_clears_allocations() {
923        let pool = small_pool(1, 32);
924        let mut arena = pool.lease().unwrap();
925        let _: &mut [u8] = arena.alloc::<u8>(32).unwrap();
926        // Cap reached.
927        assert!(matches!(
928            arena.alloc::<u8>(1),
929            Err(Error::ResourceExhausted(_))
930        ));
931        arena.reset();
932        // After reset we can allocate again.
933        let _: &mut [u8] = arena.alloc::<u8>(32).unwrap();
934    }
935}