From d2b7dcd6b5be44e215c20a8d909b944a6334bdaa Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 20 May 2026 10:05:37 -0700 Subject: [PATCH 01/17] Add more details to plan --- PLANNED.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PLANNED.md b/PLANNED.md index 79c8829..6bc3962 100644 --- a/PLANNED.md +++ b/PLANNED.md @@ -180,7 +180,7 @@ The subview analogue (`BStackGuardedSliceSubview`) would be expressed as a flag ## Adding `BStackVec` for typed vector storage -**Feature flag:** `BSTACK_FEATURE_SET` +**Feature flag:** `set` **Breaking change:** No (additive; new type only) ### Motivation @@ -247,6 +247,7 @@ When capacity is exceeded, `BStackVec` would use the `BStack`'s `realloc` to gro ### Open questions +- **Initial capacity:** Should `new()` create an empty vector with zero capacity, or should it allocate a small default capacity (e.g., 4 or 8 elements) to avoid immediate reallocations on first push? - **Generic bounds on `T`:** Should `T` be required to implement `Copy`, or should a `Drop` implementation be provided for types with destructors? A `Drop` impl would be complex, as it must be called on remaining elements when the vec is deallocated. - **Error handling:** Should methods return `Result` for all operations, or should some (e.g., `len()`, `capacity()`) be infallible? Returning `Result` allows for better error propagation but may be more verbose for simple accessors. - **Initialization of new elements:** When growing the vector, should new elements be zero-initialized, left uninitialized (i.e. `MaybeUninit`), or should we require `T` to be `Default` and call `default()`? Zero-initialization is safer and guranteed for newly allocated space, but may be unnecessary overhead for some types. From efcee97cd250550d3f27a0cbff7a92f0e0fc2eb1 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 20 May 2026 11:18:41 -0700 Subject: [PATCH 02/17] Basic implemenation of Vec --- src/alloc/mod.rs | 4 + src/alloc/vec.rs | 408 +++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 5 +- 3 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 src/alloc/vec.rs diff --git a/src/alloc/mod.rs b/src/alloc/mod.rs index 8ff34b9..bc22e27 100644 --- a/src/alloc/mod.rs +++ b/src/alloc/mod.rs @@ -441,11 +441,15 @@ pub mod ghost_tree; pub mod guarded; pub mod linear; pub mod manual; +#[cfg(feature = "set")] +pub mod vec; #[cfg(feature = "set")] pub use first_fit::FirstFitBStackAllocator; #[cfg(feature = "set")] pub use ghost_tree::GhostTreeBstackAllocator; +#[cfg(feature = "set")] +pub use vec::{BStackVec, BStackVecIter}; #[cfg(all(feature = "guarded", feature = "atomic"))] pub use guarded::{BStackAtomicGuardedSlice, BStackAtomicGuardedSliceSubview}; #[cfg(feature = "guarded")] diff --git a/src/alloc/vec.rs b/src/alloc/vec.rs new file mode 100644 index 0000000..615a6ac --- /dev/null +++ b/src/alloc/vec.rs @@ -0,0 +1,408 @@ +//! Typed, growable vector backed by a [`BStack`] allocation. +//! +//! Requires features `alloc` and `set`. + +use super::{BStackSlice, BStackSliceAllocator}; +use std::fmt; +use std::io; +use std::marker::PhantomData; +use std::mem::size_of; + +/// Byte offset of the first element within the block (past the 16-byte header). +const HEADER_LEN: u64 = 16; + +/// A typed, growable vector backed by a [`BStack`] allocation. +/// +/// `BStackVec<'a, T, A>` mirrors the core API of [`Vec`] but stores its +/// elements inside a [`BStack`] allocation managed by allocator `A`. Every +/// mutation issues a durable sync through the allocator so the contents survive +/// a process crash. +/// +/// ## Memory layout +/// +/// ```text +/// ┌──────────────────────┬──────────────────────┬────────────────────────────┐ +/// │ len (8 B, LE u64) │ cap (8 B, LE u64) │ elements: [T; cap] │ +/// └──────────────────────┴──────────────────────┴────────────────────────────┘ +/// byte 0 byte 8 byte 16 +/// ``` +/// +/// Both `len` and `cap` are re-read from the block header on every call, so the +/// metadata is recoverable after a crash even if the `BStackVec` handle is +/// reconstructed from the raw block via [`BStackVec::from_raw_block`]. +/// +/// ## Element type +/// +/// `T` must be `Copy`. Elements are written as raw bytes and read back with +/// [`ptr::read_unaligned`]. All slots `0..len` always hold byte patterns that +/// were explicitly written by a `BStackVec` method, so reads are never issued +/// against uninitialised memory. +/// +/// ## Growth strategy +/// +/// When [`push`](BStackVec::push) would exceed the current capacity, the block +/// is reallocated to `max(cap * 2, 4)` elements. New element space is +/// zero-initialised by [`BStack::extend`]. +/// +/// ## Zeroing +/// +/// [`pop`](BStackVec::pop) zeros the vacated element slot before decrementing +/// `len`. [`truncate`](BStackVec::truncate) zeros all removed slots in a single +/// [`BStackSlice::zero_range`] call. Deallocation zeroing is delegated to the +/// allocator. +/// +/// ## Feature flags +/// +/// Requires both the `alloc` and `set` Cargo features. +pub struct BStackVec<'a, T: Copy, A: BStackSliceAllocator> { + /// The full block: header (16 B) followed by element data. + slice: BStackSlice<'a, A>, + _phantom: PhantomData, +} + +impl<'a, T: Copy, A: BStackSliceAllocator> fmt::Debug for BStackVec<'a, T, A> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.read_header() { + Ok((len, cap)) => f + .debug_struct("BStackVec") + .field("len", &len) + .field("capacity", &cap) + .finish_non_exhaustive(), + Err(e) => write!(f, "BStackVec(error reading header: {e})"), + } + } +} + +// ── private helpers ────────────────────────────────────────────────────────── + +impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { + fn elem_size() -> u64 { + size_of::() as u64 + } + + fn block_size(capacity: u64) -> u64 { + HEADER_LEN + capacity * Self::elem_size() + } + + fn elem_offset(index: u64) -> u64 { + HEADER_LEN + index * Self::elem_size() + } + + /// Re-read `(len, capacity)` from the block header on disk. + fn read_header(&self) -> io::Result<(u64, u64)> { + let hdr = self.slice.read_range(0, 16)?; + let len = u64::from_le_bytes(hdr[0..8].try_into().unwrap()); + let cap = u64::from_le_bytes(hdr[8..16].try_into().unwrap()); + Ok((len, cap)) + } + + fn write_len_field(&self, len: u64) -> io::Result<()> { + self.slice.write_range(0, &len.to_le_bytes()) + } + + fn write_cap_field(&self, cap: u64) -> io::Result<()> { + self.slice.write_range(8, &cap.to_le_bytes()) + } + + fn write_header(&self, len: u64, cap: u64) -> io::Result<()> { + let mut hdr = [0u8; 16]; + hdr[0..8].copy_from_slice(&len.to_le_bytes()); + hdr[8..16].copy_from_slice(&cap.to_le_bytes()); + self.slice.write_range(0, &hdr) + } + + fn read_elem_at(&self, index: u64) -> io::Result { + let size = size_of::(); + let start = Self::elem_offset(index); + let bytes = self.slice.read_range(start, start + size as u64)?; + // SAFETY: `bytes` has exactly `size_of::()` bytes. Every slot + // `0..len` was written with a valid `T` value before this read. + Ok(unsafe { std::ptr::read_unaligned(bytes.as_ptr() as *const T) }) + } + + fn write_elem_at(&self, index: u64, value: T) -> io::Result<()> { + let size = size_of::(); + let start = Self::elem_offset(index); + // SAFETY: `value` is a valid, initialized `T`; we borrow its bytes. + let bytes = + unsafe { std::slice::from_raw_parts(&value as *const T as *const u8, size) }; + self.slice.write_range(start, bytes) + } + + fn zero_elem_at(&self, index: u64) -> io::Result<()> { + self.slice.zero_range(Self::elem_offset(index), Self::elem_size()) + } + + /// Reallocate the block to hold `new_cap` elements, updating `self.slice`. + fn grow_to(&mut self, new_cap: u64) -> io::Result<()> { + let new_size = Self::block_size(new_cap); + let new_slice = self.slice.allocator().realloc(self.slice, new_size)?; + self.slice = new_slice; + Ok(()) + } +} + +// ── public API ──────────────────────────────────────────────────────────────── + +impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { + /// Create an empty `BStackVec` with zero capacity. + /// + /// Allocates a 16-byte block for the header only. The first + /// [`push`](Self::push) will trigger a reallocation to 4 elements. + pub fn new(alloc: &'a A) -> io::Result { + let slice = alloc.alloc(HEADER_LEN)?; + // Header is zero-initialised by the allocator: len=0, cap=0. + Ok(Self { + slice, + _phantom: PhantomData, + }) + } + + /// Create an empty `BStackVec` pre-sized for at least `capacity` elements. + pub fn with_capacity(capacity: u64, alloc: &'a A) -> io::Result { + let slice = alloc.alloc(Self::block_size(capacity))?; + let vec = Self { + slice, + _phantom: PhantomData, + }; + // len is already 0 (zeroed by alloc); write the non-zero cap field. + vec.write_cap_field(capacity)?; + Ok(vec) + } + + /// Allocate a `BStackVec` and populate it from a Rust slice. + /// + /// The resulting vec has `len == capacity == data.len()`. + pub fn from_slice(data: &[T], alloc: &'a A) -> io::Result { + let len = data.len() as u64; + let slice = alloc.alloc(Self::block_size(len))?; + let vec = Self { + slice, + _phantom: PhantomData, + }; + if len > 0 { + // Write len and cap; elements are written individually below. + vec.write_header(len, len)?; + for (i, &item) in data.iter().enumerate() { + vec.write_elem_at(i as u64, item)?; + } + } + Ok(vec) + } + + /// Reconstruct a `BStackVec` from a raw block slice. + /// + /// # Safety + /// + /// `slice` must be the original allocation handle (not a sub-slice) returned + /// by one of the `BStackVec` constructors on the same allocator, and the + /// block header must have been written by a `BStackVec`. Passing an + /// unrelated slice or one with a mismatched element type is undefined + /// behaviour. + pub unsafe fn from_raw_block(slice: BStackSlice<'a, A>) -> Self { + Self { + slice, + _phantom: PhantomData, + } + } + + /// Return the number of elements currently stored. + /// + /// Re-reads `len` from the block header on every call. + pub fn len(&self) -> io::Result { + Ok(self.read_header()?.0) + } + + /// Return the number of elements the current allocation can hold without + /// reallocation. + /// + /// Re-reads `cap` from the block header on every call. + pub fn capacity(&self) -> io::Result { + Ok(self.read_header()?.1) + } + + /// Return `true` if the vec contains no elements. + pub fn is_empty(&self) -> io::Result { + Ok(self.len()? == 0) + } + + /// Return the element at `index`, or `None` if `index >= len`. + pub fn get(&self, index: u64) -> io::Result> { + let (len, _) = self.read_header()?; + if index >= len { + return Ok(None); + } + Ok(Some(self.read_elem_at(index)?)) + } + + /// Return a [`BStackSlice`] spanning only the populated element bytes. + /// + /// The slice covers `[16, 16 + len * size_of::())` within the block. + /// It is a sub-slice and must **not** be passed to `realloc` or `dealloc`; + /// use [`raw_block`](Self::raw_block) for that. + pub fn as_slice(&self) -> io::Result> { + let (len, _) = self.read_header()?; + Ok(self + .slice + .subslice(HEADER_LEN, HEADER_LEN + len * Self::elem_size())) + } + + /// Append `value` to the end of the vec. + /// + /// If `len == capacity`, reallocates to `max(cap * 2, 4)` elements before + /// writing. + pub fn push(&mut self, value: T) -> io::Result<()> { + let (len, cap) = self.read_header()?; + if len == cap { + let new_cap = cap.saturating_mul(2).max(4); + self.grow_to(new_cap)?; + self.write_cap_field(new_cap)?; + } + self.write_elem_at(len, value)?; + self.write_len_field(len + 1) + } + + /// Remove and return the last element, or `None` if empty. + /// + /// The vacated slot is zeroed before `len` is decremented. + pub fn pop(&mut self) -> io::Result> { + let (len, _) = self.read_header()?; + if len == 0 { + return Ok(None); + } + let value = self.read_elem_at(len - 1)?; + self.zero_elem_at(len - 1)?; + self.write_len_field(len - 1)?; + Ok(Some(value)) + } + + /// Shorten the vec to `new_len` elements. + /// + /// No-op when `new_len >= len`. Removed slots are zeroed in a single + /// [`BStackSlice::zero_range`] call; capacity is unchanged. + pub fn truncate(&mut self, new_len: u64) -> io::Result<()> { + let (len, _) = self.read_header()?; + if new_len >= len { + return Ok(()); + } + let start = Self::elem_offset(new_len); + let removed_bytes = (len - new_len) * Self::elem_size(); + self.slice.zero_range(start, removed_bytes)?; + self.write_len_field(new_len) + } + + /// Remove all elements without releasing the allocation. + /// + /// Equivalent to `truncate(0)`. + pub fn clear(&mut self) -> io::Result<()> { + self.truncate(0) + } + + /// Reserve capacity for at least `additional` more elements. + /// + /// After this call `capacity() >= len() + additional`. Does nothing if + /// the current capacity is already sufficient. + pub fn reserve(&mut self, additional: u64) -> io::Result<()> { + let (len, cap) = self.read_header()?; + let needed = len.checked_add(additional).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "BStackVec::reserve: capacity overflow", + ) + })?; + if needed <= cap { + return Ok(()); + } + let new_cap = needed.max(cap.saturating_mul(2)); + self.grow_to(new_cap)?; + self.write_cap_field(new_cap)?; + Ok(()) + } + + /// Set the length to `new_len`, filling any new slots with `value`. + /// + /// If `new_len <= len`, equivalent to [`truncate`](Self::truncate) and + /// `value` is ignored. + pub fn resize(&mut self, new_len: u64, value: T) -> io::Result<()> { + let (len, _) = self.read_header()?; + if new_len <= len { + return self.truncate(new_len); + } + self.reserve(new_len - len)?; + for i in len..new_len { + self.write_elem_at(i, value)?; + } + self.write_len_field(new_len) + } + + /// Return an iterator over the elements. + /// + /// `len` is snapshotted at construction time. The vec is borrowed + /// immutably for the iterator's lifetime, preventing concurrent mutation. + /// Each element is read from disk on demand; errors surface as + /// `io::Result::Err` items. + pub fn iter(&self) -> io::Result> { + let (len, _) = self.read_header()?; + Ok(BStackVecIter { + vec: self, + index: 0, + len, + }) + } + + /// Return the underlying block slice (header + all allocated element space). + /// + /// This is the original allocation handle and may be passed to + /// [`BStackAllocator::realloc`] or [`BStackAllocator::dealloc`]. + pub fn raw_block(&self) -> BStackSlice<'a, A> { + self.slice + } + + /// Consume the vec and return the underlying block slice. + /// + /// The caller takes responsibility for the allocation. Reconstruct with + /// [`BStackVec::from_raw_block`]. + pub fn into_raw_block(self) -> BStackSlice<'a, A> { + self.slice + } +} + +// ── iterator ────────────────────────────────────────────────────────────────── + +/// An iterator over the elements of a [`BStackVec`]. +/// +/// Constructed by [`BStackVec::iter`]. `len` is snapshotted at construction; +/// elements pushed after construction are not visible. Each element is read +/// from disk on demand; I/O errors surface as `Err` items. +pub struct BStackVecIter<'b, 'a: 'b, T: Copy, A: BStackSliceAllocator> { + vec: &'b BStackVec<'a, T, A>, + index: u64, + len: u64, +} + +impl<'b, 'a: 'b, T: Copy, A: BStackSliceAllocator> fmt::Debug for BStackVecIter<'b, 'a, T, A> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BStackVecIter") + .field("index", &self.index) + .field("len", &self.len) + .finish_non_exhaustive() + } +} + +impl<'b, 'a: 'b, T: Copy, A: BStackSliceAllocator> Iterator for BStackVecIter<'b, 'a, T, A> { + type Item = io::Result; + + fn next(&mut self) -> Option { + if self.index >= self.len { + return None; + } + let result = self.vec.read_elem_at(self.index); + self.index += 1; + Some(result) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = (self.len - self.index) as usize; + (remaining, Some(remaining)) + } +} diff --git a/src/lib.rs b/src/lib.rs index 2020983..a73158a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -417,7 +417,10 @@ pub use alloc::{ LinearBStackAllocator, ManualAllocator, }; #[cfg(all(feature = "alloc", feature = "set"))] -pub use alloc::{BStackSliceWriter, FirstFitBStackAllocator, GhostTreeBstackAllocator}; +pub use alloc::{ + BStackSliceWriter, BStackVec, BStackVecIter, FirstFitBStackAllocator, + GhostTreeBstackAllocator, +}; #[cfg(all(feature = "guarded", feature = "atomic"))] pub use alloc::{BStackAtomicGuardedSlice, BStackAtomicGuardedSliceSubview}; From e32badde34cdc98474112211367f6c2211891474 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 20 May 2026 11:19:30 -0700 Subject: [PATCH 03/17] Add to CHANGLOG --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08955d7..9a93a91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`BStackVec<'a, T: Copy, A: BStackSliceAllocator>`** (`alloc` + `set` features): A typed, growable vector backed by a `BStack` allocation, mirroring the core `Vec` API. The 16-byte block header stores `len` and `capacity` as little-endian `u64` values; elements follow immediately. The header is re-read from disk on every call, so the metadata is recoverable after a crash by reconstructing the handle via `BStackVec::from_raw_block`. Key design points: + - **Growth**: when `push` would exceed capacity, the block is reallocated to `max(cap * 2, 4)` elements via the allocator's `realloc`. New element space is zero-initialised by `BStack::extend`. + - **Zeroing on pop**: `pop` zeros the vacated slot before decrementing `len`; `truncate` zeros all removed slots in a single `BStackSlice::zero_range` call. Deallocation zeroing is delegated to the allocator. + - **API**: `new`, `with_capacity`, `from_slice`, `unsafe from_raw_block`, `len`, `capacity`, `is_empty`, `get`, `as_slice`, `push`, `pop`, `truncate`, `clear`, `reserve`, `resize`, `iter`, `raw_block`, `into_raw_block`. + - **Iterator**: `BStackVecIter<'b, 'a, T, A>` borrows the vec immutably for its lifetime (preventing concurrent mutation), snapshots `len` at construction, and yields `io::Result` per element read from disk on demand. + ### Fixed - **`FirstFitBStackAllocator::dealloc` — double-free** (`alloc` + `set` features): `dealloc` now returns `InvalidInput` if the block header's `is_free` flag is already set. Previously a double-free wrote a self-referential free-list entry; because `recovery_needed` was cleared normally, the corruption survived reopen and made every subsequent allocation fail. From 4b7387a93485e74caa8cb1b6a5cfe2e670bc7791 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 20 May 2026 11:21:48 -0700 Subject: [PATCH 04/17] Relevant documentation --- PLANNED.md | 5 ++--- README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++- src/alloc/mod.rs | 12 ++++++++++- src/lib.rs | 11 +++++++++- 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/PLANNED.md b/PLANNED.md index 6bc3962..972b1bb 100644 --- a/PLANNED.md +++ b/PLANNED.md @@ -180,7 +180,7 @@ The subview analogue (`BStackGuardedSliceSubview`) would be expressed as a flag ## Adding `BStackVec` for typed vector storage -**Feature flag:** `set` +**Feature flag:** `set` and `alloc` **Breaking change:** No (additive; new type only) ### Motivation @@ -213,7 +213,7 @@ impl<'a, T: Copy, A: BStackAllocator> BStackVec<'a, T, A> { // Accessors pub fn len(&self) -> Result; pub fn capacity(&self) -> Result; - pub fn is_empty(&self) -> Result; + pub fn is_empty(&self) -> Result; // Element access pub fn get(&self, index: u64) -> Result, A::Error>; @@ -229,7 +229,6 @@ impl<'a, T: Copy, A: BStackAllocator> BStackVec<'a, T, A> { // Iteration pub fn iter(&self) -> Result, A::Error>; - pub fn iter_mut(&mut self) -> Result, A::Error>; } ``` diff --git a/README.md b/README.md index 38d96a9..b438a77 100644 --- a/README.md +++ b/README.md @@ -417,7 +417,7 @@ bstack = { version = "0.2", features = ["set"] } ### `alloc` -Enables the region-management layer on top of `BStack`: `BStackAllocator`, `BStackBulkAllocator`, `BStackSlice`, `BStackSliceReader`, `LinearBStackAllocator`, `FirstFitBStackAllocator`, `GhostTreeBstackAllocator`, `ManualAllocator`, and the `BStackSliceAllocator` supertrait. +Enables the region-management layer on top of `BStack`: `BStackAllocator`, `BStackBulkAllocator`, `BStackSlice`, `BStackSliceReader`, `LinearBStackAllocator`, `FirstFitBStackAllocator`, `GhostTreeBstackAllocator`, `ManualAllocator`, and the `BStackSliceAllocator` supertrait. Combined with `set`, also enables `BStackSliceWriter`, `BStackVec`, and `BStackVecIter`. ```toml [dependencies] @@ -611,6 +611,59 @@ assert_eq!(c.start(), a.start()); let stack = alloc.into_stack(); ``` +### `BStackVec<'a, T, A>` (`alloc + set` features) + +A typed, growable vector backed by a `BStack` allocation, mirroring the core `Vec` API. + +```toml +[dependencies] +bstack = { version = "0.2", features = ["alloc", "set"] } +``` + +#### Memory layout + +``` +┌──────────────────────┬──────────────────────┬────────────────────────┐ +│ len (8 B, LE u64) │ cap (8 B, LE u64) │ elements: [T; cap] │ +└──────────────────────┴──────────────────────┴────────────────────────┘ + byte 0 byte 8 byte 16 +``` + +The header is re-read from disk on every call, so the `(len, cap)` metadata is +recoverable after a crash by reconstructing the handle from the raw block via +`BStackVec::from_raw_block`. + +#### Key behaviour + +- **Growth**: `push` reallocates to `max(cap × 2, 4)` elements when `len == cap`. New element space is zero-initialised by `BStack::extend`. +- **Zeroing on pop**: `pop` zeros the vacated slot before decrementing `len`. `truncate` zeros all removed slots in a single `BStackSlice::zero_range` call. Deallocation zeroing is delegated to the allocator. +- **Iterator**: `BStackVecIter` borrows the vec immutably for its lifetime (preventing concurrent mutation) and yields `io::Result` per element, reading from disk on demand. + +#### Example + +```rust +use bstack::{BStack, BStackVec, LinearBStackAllocator}; + +let alloc = LinearBStackAllocator::new(BStack::open("vec.bstack")?); + +let mut v: BStackVec = BStackVec::new(&alloc)?; +v.push(1)?; +v.push(2)?; +v.push(3)?; + +assert_eq!(v.len()?, 3); +assert_eq!(v.get(1)?, Some(2)); +assert_eq!(v.pop()?, Some(3)); + +for item in v.iter()? { + println!("{}", item?); +} + +alloc.dealloc(v.into_raw_block())?; +``` + +--- + ### Lifetime model `BStackSlice<'a, A>` borrows the **allocator** for `'a`, not the `BStack` diff --git a/src/alloc/mod.rs b/src/alloc/mod.rs index bc22e27..272fda6 100644 --- a/src/alloc/mod.rs +++ b/src/alloc/mod.rs @@ -42,6 +42,15 @@ //! strict total order. All memory is kept zeroed: the BStack zeroes on //! extension, and the allocator zeroes on free. //! +//! * [`BStackVec`](BStackVec) — a typed, growable vector backed by a +//! [`BStack`] allocation (requires both `alloc` **and** `set`). Mirrors the +//! core [`Vec`] API: `push`, `pop`, `get`, `truncate`, `reserve`, +//! `resize`, `iter`, and more. The 16-byte block header stores `len` and +//! `capacity` as little-endian `u64`s so the state is recoverable after a +//! crash. `push` reallocates at `max(cap × 2, 4)`; `pop` zeros the vacated +//! slot; `truncate` zeros removed slots in a single +//! [`zero_range`](BStackSlice::zero_range) call. +//! //! # Lifetime model //! //! `BStackSlice<'a, A>` borrows the **allocator** `A` for `'a`, not the @@ -69,7 +78,8 @@ //! bstack = { version = "0.1", features = ["alloc"] } //! ``` //! -//! In-place slice writes ([`BStackSliceWriter`]) additionally require `set`: +//! In-place slice writes ([`BStackSliceWriter`]), [`FirstFitBStackAllocator`], +//! and [`BStackVec`] additionally require `set`: //! //! ```toml //! bstack = { version = "0.1", features = ["alloc", "set"] } diff --git a/src/lib.rs b/src/lib.rs index a73158a..a6dab39 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -299,7 +299,7 @@ //! | Feature | Description | //! |---------|-------------| //! | `set` | Enables [`BStack::set`] and [`BStack::zero`] — in-place overwrite of existing payload bytes (or with zeros) without changing the file size. | -//! | `alloc` | Enables [`BStackAllocator`], [`BStackBulkAllocator`], [`BStackSlice`], [`BStackSliceReader`], and [`LinearBStackAllocator`] — region-based allocation over a `BStack` payload. | +//! | `alloc` | Enables [`BStackAllocator`], [`BStackBulkAllocator`], [`BStackSlice`], [`BStackSliceReader`], and [`LinearBStackAllocator`] — region-based allocation over a `BStack` payload. Combined with `set`, also enables [`BStackSliceWriter`], [`FirstFitBStackAllocator`], [`GhostTreeBstackAllocator`], and [`BStackVec`]. | //! | `atomic` | Enables [`BStack::atrunc`], [`BStack::splice`], [`BStack::splice_into`], [`BStack::try_extend`], [`BStack::try_discard`], and [`BStack::replace`] — compound read-modify-write operations that hold the write lock across what would otherwise be separate calls. Combined with `set`, also enables [`BStack::swap`], [`BStack::swap_into`], [`BStack::cas`], and [`BStack::process`]. | //! //! Enable with: @@ -354,6 +354,15 @@ //! Provides O(log n) allocation and deallocation with crash recovery through //! tree rebalancing on mount. //! +//! * [`BStackVec`]`<'a, T: Copy, A>` — a typed, growable vector backed by a +//! [`BStack`] allocation (requires `alloc` + `set`). Mirrors the core +//! [`Vec`] API: `new`, `with_capacity`, `from_slice`, `push`, `pop`, +//! `get`, `as_slice`, `truncate`, `clear`, `reserve`, `resize`, and `iter`. +//! The block stores a 16-byte header (`len`, `cap`) followed by the element +//! data; the header is re-read on every call for crash recoverability. `push` +//! doubles capacity (minimum 4); `pop` zeros the vacated slot; `truncate` +//! zeros all removed slots in a single call. +//! //! ## Lifetime model //! //! `BStackSlice<'a, A>` borrows the **allocator** for `'a`, not the From 13197fcb7cff13562fe4db69a22c55f0f1156ce8 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 20 May 2026 11:23:41 -0700 Subject: [PATCH 05/17] Cargo autofix --- src/alloc/mod.rs | 4 ++-- src/alloc/vec.rs | 22 +++++++++++----------- src/lib.rs | 3 +-- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/alloc/mod.rs b/src/alloc/mod.rs index 272fda6..cde29b1 100644 --- a/src/alloc/mod.rs +++ b/src/alloc/mod.rs @@ -458,11 +458,11 @@ pub mod vec; pub use first_fit::FirstFitBStackAllocator; #[cfg(feature = "set")] pub use ghost_tree::GhostTreeBstackAllocator; -#[cfg(feature = "set")] -pub use vec::{BStackVec, BStackVecIter}; #[cfg(all(feature = "guarded", feature = "atomic"))] pub use guarded::{BStackAtomicGuardedSlice, BStackAtomicGuardedSliceSubview}; #[cfg(feature = "guarded")] pub use guarded::{BStackGuardedSlice, BStackGuardedSliceSubview}; pub use linear::LinearBStackAllocator; pub use manual::ManualAllocator; +#[cfg(feature = "set")] +pub use vec::{BStackVec, BStackVecIter}; diff --git a/src/alloc/vec.rs b/src/alloc/vec.rs index 615a6ac..406525e 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -11,10 +11,10 @@ use std::mem::size_of; /// Byte offset of the first element within the block (past the 16-byte header). const HEADER_LEN: u64 = 16; -/// A typed, growable vector backed by a [`BStack`] allocation. +/// A typed, growable vector backed by a [`crate::BStack`] allocation. /// /// `BStackVec<'a, T, A>` mirrors the core API of [`Vec`] but stores its -/// elements inside a [`BStack`] allocation managed by allocator `A`. Every +/// elements inside a [`crate::BStack`] allocation managed by allocator `A`. Every /// mutation issues a durable sync through the allocator so the contents survive /// a process crash. /// @@ -34,7 +34,7 @@ const HEADER_LEN: u64 = 16; /// ## Element type /// /// `T` must be `Copy`. Elements are written as raw bytes and read back with -/// [`ptr::read_unaligned`]. All slots `0..len` always hold byte patterns that +/// [`std::ptr::read_unaligned`]. All slots `0..len` always hold byte patterns that /// were explicitly written by a `BStackVec` method, so reads are never issued /// against uninitialised memory. /// @@ -42,7 +42,7 @@ const HEADER_LEN: u64 = 16; /// /// When [`push`](BStackVec::push) would exceed the current capacity, the block /// is reallocated to `max(cap * 2, 4)` elements. New element space is -/// zero-initialised by [`BStack::extend`]. +/// zero-initialised by [`crate::BStack::extend`]. /// /// ## Zeroing /// @@ -97,18 +97,18 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { } fn write_len_field(&self, len: u64) -> io::Result<()> { - self.slice.write_range(0, &len.to_le_bytes()) + self.slice.write_range(0, len.to_le_bytes()) } fn write_cap_field(&self, cap: u64) -> io::Result<()> { - self.slice.write_range(8, &cap.to_le_bytes()) + self.slice.write_range(8, cap.to_le_bytes()) } fn write_header(&self, len: u64, cap: u64) -> io::Result<()> { let mut hdr = [0u8; 16]; hdr[0..8].copy_from_slice(&len.to_le_bytes()); hdr[8..16].copy_from_slice(&cap.to_le_bytes()); - self.slice.write_range(0, &hdr) + self.slice.write_range(0, hdr) } fn read_elem_at(&self, index: u64) -> io::Result { @@ -124,13 +124,13 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { let size = size_of::(); let start = Self::elem_offset(index); // SAFETY: `value` is a valid, initialized `T`; we borrow its bytes. - let bytes = - unsafe { std::slice::from_raw_parts(&value as *const T as *const u8, size) }; + let bytes = unsafe { std::slice::from_raw_parts(&value as *const T as *const u8, size) }; self.slice.write_range(start, bytes) } fn zero_elem_at(&self, index: u64) -> io::Result<()> { - self.slice.zero_range(Self::elem_offset(index), Self::elem_size()) + self.slice + .zero_range(Self::elem_offset(index), Self::elem_size()) } /// Reallocate the block to hold `new_cap` elements, updating `self.slice`. @@ -353,7 +353,7 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { /// Return the underlying block slice (header + all allocated element space). /// /// This is the original allocation handle and may be passed to - /// [`BStackAllocator::realloc`] or [`BStackAllocator::dealloc`]. + /// [`crate::BStackAllocator::realloc`] or [`crate::BStackAllocator::dealloc`]. pub fn raw_block(&self) -> BStackSlice<'a, A> { self.slice } diff --git a/src/lib.rs b/src/lib.rs index a6dab39..a9c1909 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -427,8 +427,7 @@ pub use alloc::{ }; #[cfg(all(feature = "alloc", feature = "set"))] pub use alloc::{ - BStackSliceWriter, BStackVec, BStackVecIter, FirstFitBStackAllocator, - GhostTreeBstackAllocator, + BStackSliceWriter, BStackVec, BStackVecIter, FirstFitBStackAllocator, GhostTreeBstackAllocator, }; #[cfg(all(feature = "guarded", feature = "atomic"))] From 3491678384e282b041991fb3cee933af69d702d2 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 20 May 2026 12:27:01 -0700 Subject: [PATCH 06/17] Improve documentation --- src/alloc/mod.rs | 6 ------ src/alloc/vec.rs | 41 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/alloc/mod.rs b/src/alloc/mod.rs index bec1af5..9b20ea8 100644 --- a/src/alloc/mod.rs +++ b/src/alloc/mod.rs @@ -85,12 +85,6 @@ //! bstack = { version = "0.1", features = ["alloc", "set"] } //! ``` //! -//! [`FirstFitBStackAllocator`] requires **both** `alloc` and `set`: -//! -//! ```toml -//! bstack = { version = "0.1", features = ["alloc", "set"] } -//! ``` -//! //! # Realloc and dealloc: slice origin requirement //! //! [`BStackAllocator::realloc`] and [`BStackAllocator::dealloc`] are only diff --git a/src/alloc/vec.rs b/src/alloc/vec.rs index 406525e..fa99d84 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -51,6 +51,40 @@ const HEADER_LEN: u64 = 16; /// [`BStackSlice::zero_range`] call. Deallocation zeroing is delegated to the /// allocator. /// +/// ## Thread safety +/// +/// `BStackVec` is `Send` when `T: Send` and `A: Sync`, and `Sync` when +/// `T: Sync` and `A: Sync` (both conditions hold for all allocators in this +/// library). The underlying [`crate::BStack`] serialises concurrent writers +/// through an internal `RwLock`, so multiple threads may call `&self` methods +/// concurrently. Methods that take `&mut self` (`push`, `pop`, `truncate`, +/// `clear`, `reserve`, `resize`) require exclusive access and may not be called +/// from multiple threads simultaneously. +/// +/// ## Crash consistency and atomicity +/// +/// Every individual [`crate::BStack`] call (`set`, `zero`, `extend`, `discard`) +/// is durably synced before returning and is crash-safe in isolation. However, +/// all multi-step `BStackVec` methods issue **two or more** such calls in +/// sequence and are **not atomic** with respect to process crashes. +/// +/// The crash-recovery state for each mutating method is: +/// +/// | Method | Step order | Crash-recovery state | +/// |--------|-----------|----------------------| +/// | `push` (no realloc) | write element → increment `len` | Crash after element write: element on disk but `len` not updated; slot is effectively invisible. Re-running `push` with the same value recovers correctly. | +/// | `push` (with realloc) | `realloc` → write `cap` → write element → increment `len` | Crash at any point: header re-read on next open reflects the committed state; worst case is a leaked block (with `LinearBStackAllocator`) or a stale cap value. | +/// | `pop` | read element → zero slot → decrement `len` | Crash after zero but before `len` decrement: element is zeroed on disk but `len` still counts it; `get(len-1)` returns a zero-bit `T`. Decrementing `len` on the next `pop` call removes it cleanly. | +/// | `truncate` | zero removed slots → write `len` | Crash after zeroing but before `len` write: logically removed elements are on disk as zeros; `len` still points past them. Calling `truncate` again to the same target is idempotent. | +/// | `resize` (grow) | `reserve` → write elements → write `len` | Same as `push` repeated; elements between the old and new `len` may be partially written. | +/// | `clear` | (delegates to `truncate(0)`) | See `truncate`. | +/// | `reserve` | `realloc` → write `cap` | Crash between the two: cap field may reflect the old value; the block is larger than cap indicates. Harmless — the next `push` re-checks and may realloc again unnecessarily. | +/// +/// In all cases the header is re-read from disk on the next call, so the +/// on-disk `(len, cap)` always reflects the last fully committed step. The +/// [`from_raw_block`](BStackVec::from_raw_block) constructor can be used to +/// reconstruct the handle after a reopen without any additional recovery logic. +/// /// ## Feature flags /// /// Requires both the `alloc` and `set` Cargo features. @@ -91,6 +125,8 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { /// Re-read `(len, capacity)` from the block header on disk. fn read_header(&self) -> io::Result<(u64, u64)> { let hdr = self.slice.read_range(0, 16)?; + // `read_range(0, 16)` returns exactly 16 bytes on success; the slices + // are 8 bytes each so try_into() is always Ok — these cannot panic. let len = u64::from_le_bytes(hdr[0..8].try_into().unwrap()); let cap = u64::from_le_bytes(hdr[8..16].try_into().unwrap()); Ok((len, cap)) @@ -402,7 +438,10 @@ impl<'b, 'a: 'b, T: Copy, A: BStackSliceAllocator> Iterator for BStackVecIter<'b } fn size_hint(&self) -> (usize, Option) { - let remaining = (self.len - self.index) as usize; + // `self.index <= self.len` is invariant; subtraction cannot underflow. + // On 32-bit platforms the cast saturates to usize::MAX, which is a + // valid (conservative) hint per the Iterator contract. + let remaining = (self.len - self.index).min(usize::MAX as u64) as usize; (remaining, Some(remaining)) } } From 159d1dc576e9a50d1b3019703dbd6d60a33b5dc1 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 20 May 2026 12:28:50 -0700 Subject: [PATCH 07/17] Add tests to vec --- src/alloc/vec.rs | 412 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 412 insertions(+) diff --git a/src/alloc/vec.rs b/src/alloc/vec.rs index fa99d84..68bbe81 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -445,3 +445,415 @@ impl<'b, 'a: 'b, T: Copy, A: BStackSliceAllocator> Iterator for BStackVecIter<'b (remaining, Some(remaining)) } } + +// ── tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::BStack; + use crate::alloc::LinearBStackAllocator; + use std::sync::atomic::{AtomicU64, Ordering}; + + // ── helpers ─────────────────────────────────────────────────────────────── + + fn temp_path() -> std::path::PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let pid = std::process::id(); + std::env::temp_dir().join(format!("bstack_vec_test_{pid}_{id}.bin")) + } + + struct Guard(std::path::PathBuf); + impl Drop for Guard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + + fn make_alloc() -> (LinearBStackAllocator, std::path::PathBuf) { + let path = temp_path(); + let alloc = LinearBStackAllocator::new(BStack::open(&path).unwrap()); + (alloc, path) + } + + // ── constructors and header recovery ───────────────────────────────────── + + #[test] + fn new_is_empty_with_zero_cap() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let v: BStackVec = BStackVec::new(&alloc).unwrap(); + assert_eq!(v.len().unwrap(), 0); + assert_eq!(v.capacity().unwrap(), 0); + assert!(v.is_empty().unwrap()); + } + + #[test] + fn with_capacity_has_zero_len_and_correct_cap() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let v: BStackVec = BStackVec::with_capacity(8, &alloc).unwrap(); + assert_eq!(v.len().unwrap(), 0); + assert_eq!(v.capacity().unwrap(), 8); + assert!(v.is_empty().unwrap()); + } + + #[test] + fn from_slice_roundtrip() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let src = [10u64, 20, 30, 40, 50]; + let v = BStackVec::from_slice(&src, &alloc).unwrap(); + assert_eq!(v.len().unwrap(), 5); + assert_eq!(v.capacity().unwrap(), 5); + for (i, &expected) in src.iter().enumerate() { + assert_eq!(v.get(i as u64).unwrap(), Some(expected)); + } + assert_eq!(v.get(5).unwrap(), None); + } + + #[test] + fn from_slice_empty() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let v: BStackVec = BStackVec::from_slice(&[], &alloc).unwrap(); + assert_eq!(v.len().unwrap(), 0); + assert_eq!(v.capacity().unwrap(), 0); + } + + #[test] + fn raw_block_roundtrip() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + + let mut v: BStackVec = BStackVec::new(&alloc).unwrap(); + v.push(1).unwrap(); + v.push(2).unwrap(); + v.push(3).unwrap(); + + let block = v.into_raw_block(); + // Reconstruct from raw block and verify header and elements survive. + let v2: BStackVec = unsafe { BStackVec::from_raw_block(block) }; + assert_eq!(v2.len().unwrap(), 3); + assert_eq!(v2.get(0).unwrap(), Some(1)); + assert_eq!(v2.get(1).unwrap(), Some(2)); + assert_eq!(v2.get(2).unwrap(), Some(3)); + } + + #[test] + fn reopen_header_recovery() { + // Verify that (len, cap) survive a drop-and-reopen via from_raw_block. + let path = temp_path(); + let _g = Guard(path.clone()); + + let block_bytes = { + let alloc = LinearBStackAllocator::new(BStack::open(&path).unwrap()); + let mut v: BStackVec = BStackVec::new(&alloc).unwrap(); + v.push(111).unwrap(); + v.push(222).unwrap(); + v.push(333).unwrap(); + // Serialise the raw block handle for later reconstruction. + let bytes: [u8; 16] = v.into_raw_block().into(); + bytes + // `alloc` (and the BStack file) are closed here. + }; + + // Reopen and reconstruct. + let alloc = LinearBStackAllocator::new(BStack::open(&path).unwrap()); + let block = unsafe { crate::alloc::BStackSlice::from_bytes(&alloc, block_bytes) }; + let v: BStackVec = unsafe { BStackVec::from_raw_block(block) }; + assert_eq!(v.len().unwrap(), 3); + assert_eq!(v.get(0).unwrap(), Some(111)); + assert_eq!(v.get(1).unwrap(), Some(222)); + assert_eq!(v.get(2).unwrap(), Some(333)); + } + + // ── push / pop / get ───────────────────────────────────────────────────── + + #[test] + fn push_pop_lifo() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v: BStackVec = BStackVec::new(&alloc).unwrap(); + for i in 0..10u64 { + v.push(i * 11).unwrap(); + } + assert_eq!(v.len().unwrap(), 10); + for i in (0..10u64).rev() { + assert_eq!(v.pop().unwrap(), Some(i * 11)); + } + assert_eq!(v.pop().unwrap(), None); + assert!(v.is_empty().unwrap()); + } + + #[test] + fn pop_zeros_vacated_slot() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v: BStackVec = BStackVec::new(&alloc).unwrap(); + v.push(0xDEAD_BEEF_CAFE_BABEu64).unwrap(); + + let block = v.raw_block(); + v.pop().unwrap(); + + // Slot 0's bytes (at block offset 16) should now be zeroed. + let slot_bytes = block.read_range(16, 24).unwrap(); + assert_eq!( + slot_bytes, [0u8; 8], + "vacated slot must be zeroed after pop" + ); + } + + #[test] + fn get_out_of_bounds_returns_none() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v: BStackVec = BStackVec::new(&alloc).unwrap(); + v.push(42).unwrap(); + assert_eq!(v.get(0).unwrap(), Some(42u32)); + assert_eq!(v.get(1).unwrap(), None); + assert_eq!(v.get(u64::MAX).unwrap(), None); + } + + // ── growth ─────────────────────────────────────────────────────────────── + + #[test] + fn push_triggers_growth_from_zero() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v: BStackVec = BStackVec::new(&alloc).unwrap(); + // First push must grow from cap=0 to cap=4. + v.push(1).unwrap(); + assert!(v.capacity().unwrap() >= 4); + assert_eq!(v.len().unwrap(), 1); + } + + #[test] + fn push_doubles_capacity_on_overflow() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v: BStackVec = BStackVec::with_capacity(2, &alloc).unwrap(); + v.push(1).unwrap(); + v.push(2).unwrap(); + let cap_before = v.capacity().unwrap(); + assert_eq!(cap_before, 2); + v.push(3).unwrap(); // triggers doubling + assert!(v.capacity().unwrap() >= 4); + assert_eq!(v.len().unwrap(), 3); + } + + // ── truncate / clear ────────────────────────────────────────────────────── + + #[test] + fn truncate_shortens_and_zeros_slots() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v: BStackVec = BStackVec::new(&alloc).unwrap(); + v.push(0xAAAA_AAAA_AAAA_AAAAu64).unwrap(); + v.push(0xBBBB_BBBB_BBBB_BBBBu64).unwrap(); + v.push(0xCCCC_CCCC_CCCC_CCCCu64).unwrap(); + + let block = v.raw_block(); + v.truncate(1).unwrap(); + + assert_eq!(v.len().unwrap(), 1); + assert_eq!(v.capacity().unwrap(), 4); // cap unchanged + + // Slots 1 and 2 (offsets 24..40) must be zeroed. + let removed = block.read_range(24, 40).unwrap(); + assert_eq!(removed, [0u8; 16], "truncated slots must be zeroed"); + } + + #[test] + fn truncate_noop_when_new_len_ge_len() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v: BStackVec = BStackVec::new(&alloc).unwrap(); + v.push(7).unwrap(); + v.truncate(5).unwrap(); // no-op + assert_eq!(v.len().unwrap(), 1); + assert_eq!(v.get(0).unwrap(), Some(7u32)); + } + + #[test] + fn clear_zeros_all_element_slots() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v: BStackVec = BStackVec::new(&alloc).unwrap(); + v.push(1).unwrap(); + v.push(2).unwrap(); + v.push(3).unwrap(); + + let block = v.raw_block(); + v.clear().unwrap(); + + assert_eq!(v.len().unwrap(), 0); + // All three element slots must be zeroed (offsets 16..40). + let elems = block.read_range(16, 16 + 3 * 8).unwrap(); + assert_eq!(elems, vec![0u8; 24]); + } + + // ── reserve / resize overflow ───────────────────────────────────────────── + + #[test] + fn reserve_noop_when_capacity_sufficient() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v: BStackVec = BStackVec::with_capacity(10, &alloc).unwrap(); + v.push(1).unwrap(); + v.reserve(5).unwrap(); // len=1, cap=10 => sufficient + assert_eq!(v.capacity().unwrap(), 10); + } + + #[test] + fn reserve_grows_when_needed() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v: BStackVec = BStackVec::new(&alloc).unwrap(); + v.push(1).unwrap(); + // len=1, cap>=4; reserve(100) must grow to at least 101. + v.reserve(100).unwrap(); + assert!(v.capacity().unwrap() >= 101); + } + + #[test] + fn reserve_overflow_returns_error() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v: BStackVec = BStackVec::new(&alloc).unwrap(); + v.push(1).unwrap(); // len=1 + // Requesting u64::MAX additional would overflow len+additional. + let err = v.reserve(u64::MAX).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + } + + #[test] + fn resize_grow_fills_with_value() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v: BStackVec = BStackVec::new(&alloc).unwrap(); + v.push(1).unwrap(); + v.resize(5, 99u32).unwrap(); + assert_eq!(v.len().unwrap(), 5); + assert_eq!(v.get(0).unwrap(), Some(1u32)); + for i in 1..5 { + assert_eq!(v.get(i).unwrap(), Some(99u32)); + } + } + + #[test] + fn resize_shrink_truncates() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v: BStackVec = BStackVec::from_slice(&[1, 2, 3, 4, 5], &alloc).unwrap(); + v.resize(2, 0).unwrap(); + assert_eq!(v.len().unwrap(), 2); + assert_eq!(v.get(0).unwrap(), Some(1u32)); + assert_eq!(v.get(1).unwrap(), Some(2u32)); + assert_eq!(v.get(2).unwrap(), None); + } + + // ── as_slice ────────────────────────────────────────────────────────────── + + #[test] + fn as_slice_covers_populated_elements_only() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let v = BStackVec::from_slice(&[10u32, 20, 30], &alloc).unwrap(); + let s = v.as_slice().unwrap(); + assert_eq!(s.len(), 3 * size_of::() as u64); + let bytes = s.read().unwrap(); + let mut expected = [0u8; 12]; + expected[0..4].copy_from_slice(&10u32.to_le_bytes()); + expected[4..8].copy_from_slice(&20u32.to_le_bytes()); + expected[8..12].copy_from_slice(&30u32.to_le_bytes()); + assert_eq!(bytes, expected); + } + + // ── iterator snapshot semantics ─────────────────────────────────────────── + + #[test] + fn iter_yields_all_elements_in_order() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let src = [3u64, 1, 4, 1, 5, 9, 2, 6]; + let v = BStackVec::from_slice(&src, &alloc).unwrap(); + let collected: Vec = v.iter().unwrap().map(|r| r.unwrap()).collect(); + assert_eq!(collected, src); + } + + #[test] + fn iter_size_hint_tracks_remaining() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let v = BStackVec::from_slice(&[1u32, 2, 3, 4, 5], &alloc).unwrap(); + let mut it = v.iter().unwrap(); + assert_eq!(it.size_hint(), (5, Some(5))); + it.next().unwrap(); + assert_eq!(it.size_hint(), (4, Some(4))); + it.next().unwrap(); + it.next().unwrap(); + assert_eq!(it.size_hint(), (2, Some(2))); + } + + #[test] + fn iter_stops_at_len_snapshot() { + // Build a vec, take an iter (which snapshots len), then verify the iter + // sees exactly that many elements. The borrow checker prevents mutation + // while the iter is live, so we verify the element count indirectly. + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let v = BStackVec::from_slice(&[10u64, 20, 30], &alloc).unwrap(); + let count = v.iter().unwrap().count(); + assert_eq!(count, 3); + } + + #[test] + fn iter_on_empty_vec_yields_nothing() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let v: BStackVec = BStackVec::new(&alloc).unwrap(); + let count = v.iter().unwrap().count(); + assert_eq!(count, 0); + } + + // ── zero-sized T ───────────────────────────────────────────────────────── + + #[test] + fn zst_push_pop_tracks_len() { + // `size_of::<()>() == 0`; the block never grows beyond the 16-byte header. + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v: BStackVec<(), _> = BStackVec::new(&alloc).unwrap(); + assert_eq!(v.len().unwrap(), 0); + v.push(()).unwrap(); + v.push(()).unwrap(); + v.push(()).unwrap(); + assert_eq!(v.len().unwrap(), 3); + assert_eq!(v.get(2).unwrap(), Some(())); + assert_eq!(v.pop().unwrap(), Some(())); + assert_eq!(v.len().unwrap(), 2); + } + + #[test] + fn zst_iter_yields_correct_count() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let v = BStackVec::from_slice(&[(), (), (), ()], &alloc).unwrap(); + let count = v.iter().unwrap().count(); + assert_eq!(count, 4); + } + + #[test] + fn zst_block_size_is_always_header_only() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v: BStackVec<(), _> = BStackVec::new(&alloc).unwrap(); + // Push many ZSTs; the raw block should stay at 16 bytes. + for _ in 0..100 { + v.push(()).unwrap(); + } + assert_eq!(v.raw_block().len(), 16); + } +} From 06e44907d8f498a7d224785c8202eb173f067746 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 20 May 2026 12:30:01 -0700 Subject: [PATCH 08/17] Remove planned section --- PLANNED.md | 75 ------------------------------------------------------ 1 file changed, 75 deletions(-) diff --git a/PLANNED.md b/PLANNED.md index 0de78ba..d6da36c 100644 --- a/PLANNED.md +++ b/PLANNED.md @@ -134,78 +134,3 @@ This prevents accidental writes while still allowing inspection of the underlyin - Would the migration burden outweigh the safety benefits? --- - -## Adding `BStackVec` for typed vector storage - -**Feature flag:** `set` and `alloc` -**Breaking change:** No (additive; new type only) - -### Motivation - -While `BStackSlice` provides a flexible byte-oriented view of allocated blocks, many applications would benefit from a typed vector abstraction that manages element layout, length tracking, and safe access patterns. A `BStackVec` type would provide a convenient API for storing and manipulating sequences of typed data on a `BStack`, while still leveraging the underlying durability and crash-safety guarantees. - -### Design - -`BStackVec` mirrors Rust's standard `Vec` API, but backed by a `BStack` allocation. It would manage its own length and capacity metadata, stored in the block header or a reserved prefix. - -#### Memory layout - -The allocation layout would consist of: -- **Header** (16 bytes): length (u64) + capacity (u64) -- **Elements**: inline typed data, stored as `[T; capacity]` - -The header occupies the first 16 bytes of the block, with element data following. This ensures that the metadata is always present and can be recovered even if the `BStackVec` handle is lost. - -#### API surface - -Core methods mirroring `Vec`: - -```rust -impl<'a, T: Copy, A: BStackAllocator> BStackVec<'a, T, A> { - // Creation and destruction - pub fn new(alloc: &'a A) -> Result; - pub fn with_capacity(capacity: u64, alloc: &'a A) -> Result; - pub fn from_slice(slice: &[T], alloc: &'a A) -> Result; - - // Accessors - pub fn len(&self) -> Result; - pub fn capacity(&self) -> Result; - pub fn is_empty(&self) -> Result; - - // Element access - pub fn get(&self, index: u64) -> Result, A::Error>; - pub fn as_slice(&self) -> Result, A::Error>; - - // Modification - pub fn push(&mut self, value: T) -> Result<(), A::Error>; - pub fn pop(&mut self) -> Result, A::Error>; - pub fn truncate(&mut self, len: u64) -> Result<(), A::Error>; - pub fn clear(&mut self) -> Result<(), A::Error>; - pub fn reserve(&mut self, additional: u64) -> Result<(), A::Error>; - pub fn resize(&mut self, new_len: u64, value: T) -> Result<(), A::Error>; - - // Iteration - pub fn iter(&self) -> Result, A::Error>; -} -``` - -#### Growth strategy - -When capacity is exceeded, `BStackVec` would use the `BStack`'s `realloc` to grow the block, similar to `Vec`'s doubling strategy (or a configurable policy). The metadata prefix would be preserved and updated with new capacity. - -#### Guarding support - -`BStackVec` should integrate with `BStackGuardedSlice` by providing a `as_guarded_slice()` method that wraps the underlying slice with a guard, enabling transparent transforms across the entire vector. - -#### Serialization considerations - -`BStackVec` should be serializable to persistent storage by writing only the populated portion (length, not capacity) and reconstructable on load by reading back the metadata from the block header. - -### Open questions - -- **Initial capacity:** Should `new()` create an empty vector with zero capacity, or should it allocate a small default capacity (e.g., 4 or 8 elements) to avoid immediate reallocations on first push? -- **Generic bounds on `T`:** Should `T` be required to implement `Copy`, or should a `Drop` implementation be provided for types with destructors? A `Drop` impl would be complex, as it must be called on remaining elements when the vec is deallocated. -- **Error handling:** Should methods return `Result` for all operations, or should some (e.g., `len()`, `capacity()`) be infallible? Returning `Result` allows for better error propagation but may be more verbose for simple accessors. -- **Initialization of new elements:** When growing the vector, should new elements be zero-initialized, left uninitialized (i.e. `MaybeUninit`), or should we require `T` to be `Default` and call `default()`? Zero-initialization is safer and guranteed for newly allocated space, but may be unnecessary overhead for some types. -- **Zeroing on deallocation:** Should empty parts of the vector be zeroed on deallocation for security, or should this be left to the caller? Zeroing adds overhead but can prevent data leakage. - From 2bc5e39be4f6b1b47c597194e45d445501892331 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 20 May 2026 12:33:33 -0700 Subject: [PATCH 09/17] Add example --- examples/bstack_vec.rs | 179 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 examples/bstack_vec.rs diff --git a/examples/bstack_vec.rs b/examples/bstack_vec.rs new file mode 100644 index 0000000..46a4d11 --- /dev/null +++ b/examples/bstack_vec.rs @@ -0,0 +1,179 @@ +//! Persistent order log using [`BStackVec`]. +//! +//! Demonstrates: +//! +//! * Storing a typed, multi-field struct (`Order`) in a `BStackVec` backed by +//! a `LinearBStackAllocator`. +//! * Iterating, querying, and mutating the live vec. +//! * Serialising the raw block handle, closing the file, reopening it, and +//! reconstructing the vec — the reopen / crash-recovery path. + +use bstack::{BStack, BStackAllocator, BStackVec, LinearBStackAllocator}; +use std::io; +use std::path::PathBuf; + +// ── Order type ──────────────────────────────────────────────────────────────── + +/// A single order record. +/// +/// All fields are fixed-width and the struct is `repr(C)` so the on-disk +/// layout is stable across compilations. Fixed-point money values use +/// integer cents (2 implied decimal places). +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq)] +struct Order { + /// Unix timestamp (seconds since epoch). + timestamp: u64, + /// Price per item in hundredths of the currency unit (e.g. 1999 = $19.99). + price_cents: i64, + /// Shipping fee in hundredths of the currency unit. + shipping_cents: i64, + /// Number of items ordered (negative for returns). + amount: i32, + /// Fulfilment class: 0 = standard, 1 = express, 2 = overnight. + order_type: u8, + _pad: [u8; 3], +} + +impl Order { + fn new( + timestamp: u64, + amount: i32, + price_cents: i64, + shipping_cents: i64, + order_type: u8, + ) -> Self { + Self { + timestamp, + amount, + price_cents, + shipping_cents, + order_type, + _pad: [0; 3], + } + } + + fn total_cents(&self) -> i64 { + self.price_cents * self.amount as i64 + self.shipping_cents + } + + fn type_name(&self) -> &'static str { + match self.order_type { + 0 => "standard", + 1 => "express", + 2 => "overnight", + _ => "unknown", + } + } +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +fn print_orders(v: &BStackVec) -> io::Result<()> { + let len = v.len()?; + println!(" {} order(s):", len); + for item in v.iter()? { + let o = item?; + println!( + " ts={:<12} {:>3} x {:>7} + {:>5} ship [{:>9}] total={:>9}¢", + o.timestamp, + o.amount, + format!("{}¢", o.price_cents), + format!("{}¢", o.shipping_cents), + o.type_name(), + o.total_cents(), + ); + } + Ok(()) +} + +// ── main ────────────────────────────────────────────────────────────────────── + +fn main() -> io::Result<()> { + let path = PathBuf::from("orders_example.bstack"); + + // ── Session 1: create and populate ─────────────────────────────────────── + + println!("=== Session 1: creating order log ==="); + + let block_bytes: [u8; 16] = { + let alloc = LinearBStackAllocator::new(BStack::open(&path)?); + let mut orders: BStackVec = BStackVec::new(&alloc)?; + + orders.push(Order::new(1_748_000_000, 2, 1999, 499, 0))?; // 2× $19.99, $4.99 ship + orders.push(Order::new(1_748_000_120, 1, 8999, 0, 1))?; // 1× $89.99, free express + orders.push(Order::new(1_748_000_300, 5, 599, 999, 0))?; // 5× $5.99, $9.99 ship + orders.push(Order::new(1_748_001_000, -1, 1999, 0, 0))?; // 1 return, $19.99 refund + + println!("After initial push:"); + print_orders(&orders)?; + println!(" capacity={}", orders.capacity()?); + + // Pop the return record, inspect it, then push a replacement. + let ret = orders.pop()?.expect("expected a record"); + assert_eq!(ret.amount, -1); + println!("\nPopped return record: {:?}", ret); + + // Replace with an overnight order. + orders.push(Order::new(1_748_001_500, 3, 2499, 1499, 2))?; + + println!("\nAfter replacement:"); + print_orders(&orders)?; + + // Serialise the raw block handle (offset + len as 16 bytes). + let bytes: [u8; 16] = orders.into_raw_block().into(); + bytes + // `alloc` (and the BStack) are dropped here, closing the file. + }; + + println!("\nFile closed. Block handle: {:?}", block_bytes); + + // ── Session 2: reopen and recover ──────────────────────────────────────── + + println!("\n=== Session 2: reopen and recover ==="); + + { + let alloc = LinearBStackAllocator::new(BStack::open(&path)?); + + // Reconstruct the BStackSlice from the serialised bytes. + // SAFETY: `block_bytes` was produced by `BStackSlice::to_bytes` in + // Session 1 on the same file; the offset and length are valid. + let block = unsafe { bstack::BStackSlice::from_bytes(&alloc, block_bytes) }; + + // Reconstruct the BStackVec. + // SAFETY: the block was created by `BStackVec::new` with the + // same element type; the header layout matches. + let orders: BStackVec = unsafe { BStackVec::from_raw_block(block) }; + + println!("Recovered {} order(s):", orders.len()?); + print_orders(&orders)?; + + // Verify a specific record survives the round-trip. + let second = orders.get(1)?.expect("expected record at index 1"); + assert_eq!(second.price_cents, 8999); + assert_eq!(second.order_type, 1); + println!("\nVerified: record[1] = {:?}", second); + + // Demonstrate resize: pad to 8 slots with a zero-value placeholder. + let placeholder = Order::new(0, 0, 0, 0, 0); + let mut orders = orders; + // We must hold the mutable vec; reconstruct a mutable binding. + // (In real code the vec would be mut from the start.) + orders.resize(8, placeholder)?; + println!("\nAfter resize to 8 (placeholder-filled):"); + print_orders(&orders)?; + + // Truncate back to the real data. + orders.truncate(4)?; + println!("\nAfter truncate to 4:"); + print_orders(&orders)?; + + // Clean up via dealloc. + alloc.dealloc(orders.into_raw_block())?; + } + + // Remove the example file. + std::fs::remove_file(&path)?; + println!("\nDone."); + Ok(()) +} From a5b6449f39f52fed2f8eb282811aee898f3ef5fe Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 20 May 2026 12:36:12 -0700 Subject: [PATCH 10/17] Fix warnings by unwrapping result --- src/alloc/vec.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/alloc/vec.rs b/src/alloc/vec.rs index 68bbe81..82ca937 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -790,10 +790,10 @@ mod tests { let v = BStackVec::from_slice(&[1u32, 2, 3, 4, 5], &alloc).unwrap(); let mut it = v.iter().unwrap(); assert_eq!(it.size_hint(), (5, Some(5))); - it.next().unwrap(); + it.next().unwrap().unwrap(); assert_eq!(it.size_hint(), (4, Some(4))); - it.next().unwrap(); - it.next().unwrap(); + it.next().unwrap().unwrap(); + it.next().unwrap().unwrap(); assert_eq!(it.size_hint(), (2, Some(2))); } From 49359c2fe6fd9326d8da0c4c55d114ecedfaacbc Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 20 May 2026 12:37:52 -0700 Subject: [PATCH 11/17] Build example with feature flags --- Cargo.toml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index cc60478..ba6502f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,34 @@ windows-sys = { version = "0.52", features = [ "Win32_System_IO", ] } +[[example]] +name = "bstack_vec" +required-features = ["alloc", "set"] + +[[example]] +name = "alloc_basic" +required-features = ["alloc", "set"] + +[[example]] +name = "alloc_typed" +required-features = ["alloc", "set"] + +[[example]] +name = "custom_alloc" +required-features = ["alloc", "set"] + +[[example]] +name = "hashmap" +required-features = ["alloc", "set"] + +[[example]] +name = "atomic_ops" +required-features = ["atomic"] + +[[example]] +name = "atomic_race" +required-features = ["atomic"] + [dev-dependencies] rand = "0.10.1" From e3c541c4af837162969f153a1bd8eb556acba66c Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 20 May 2026 13:04:02 -0700 Subject: [PATCH 12/17] Minor improvements --- examples/bstack_vec.rs | 2 +- src/alloc/vec.rs | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/examples/bstack_vec.rs b/examples/bstack_vec.rs index 46a4d11..d9f5c63 100644 --- a/examples/bstack_vec.rs +++ b/examples/bstack_vec.rs @@ -169,7 +169,7 @@ fn main() -> io::Result<()> { print_orders(&orders)?; // Clean up via dealloc. - alloc.dealloc(orders.into_raw_block())?; + orders.dealloc()?; } // Remove the example file. diff --git a/src/alloc/vec.rs b/src/alloc/vec.rs index 82ca937..23eb020 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -124,11 +124,12 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { /// Re-read `(len, capacity)` from the block header on disk. fn read_header(&self) -> io::Result<(u64, u64)> { - let hdr = self.slice.read_range(0, 16)?; + let mut hdr = [0u8; 16]; + self.slice.read_range_into(0, &mut hdr)?; // `read_range(0, 16)` returns exactly 16 bytes on success; the slices // are 8 bytes each so try_into() is always Ok — these cannot panic. - let len = u64::from_le_bytes(hdr[0..8].try_into().unwrap()); - let cap = u64::from_le_bytes(hdr[8..16].try_into().unwrap()); + let len = u64::from_le_bytes(hdr[..8].try_into().unwrap()); + let cap = u64::from_le_bytes(hdr[8..].try_into().unwrap()); Ok((len, cap)) } @@ -150,6 +151,7 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { fn read_elem_at(&self, index: u64) -> io::Result { let size = size_of::(); let start = Self::elem_offset(index); + // This has to be allocating since we need to copy to memory from disk let bytes = self.slice.read_range(start, start + size as u64)?; // SAFETY: `bytes` has exactly `size_of::()` bytes. Every slot // `0..len` was written with a valid `T` value before this read. @@ -172,6 +174,8 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { /// Reallocate the block to hold `new_cap` elements, updating `self.slice`. fn grow_to(&mut self, new_cap: u64) -> io::Result<()> { let new_size = Self::block_size(new_cap); + // SAFETY: Slice origin requirement is upheld because `self.slice` is the original allocation handle + // returned by the constructor, and `realloc` returns a new slice for the same block. let new_slice = self.slice.allocator().realloc(self.slice, new_size)?; self.slice = new_slice; Ok(()) @@ -401,6 +405,13 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { pub fn into_raw_block(self) -> BStackSlice<'a, A> { self.slice } + + /// Deallocate the underlying block and consume the vec. + pub fn dealloc(self) -> io::Result<()> { + // Slice origin requirement is upheld because `self.slice` is the original allocation handle + // returned by the constructor, and `dealloc` is only called on `self.slice`. + self.slice.allocator().dealloc(self.slice) + } } // ── iterator ────────────────────────────────────────────────────────────────── From e7b7f38c107074a1c677e36e87aac16822962af1 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 20 May 2026 13:18:35 -0700 Subject: [PATCH 13/17] Improvements for safety and add integration tests --- src/alloc/vec.rs | 167 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 159 insertions(+), 8 deletions(-) diff --git a/src/alloc/vec.rs b/src/alloc/vec.rs index 23eb020..83264d8 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -114,11 +114,28 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { size_of::() as u64 } - fn block_size(capacity: u64) -> u64 { - HEADER_LEN + capacity * Self::elem_size() + /// Compute the total block size in bytes for `capacity` elements. + /// + /// Returns `Err(InvalidInput)` if the arithmetic overflows `u64` — this + /// prevents passing a wrapped-around value to the allocator and accidentally + /// obtaining a block that is too small to hold the requested elements. + fn block_size(capacity: u64) -> io::Result { + capacity + .checked_mul(Self::elem_size()) + .and_then(|elem_bytes| elem_bytes.checked_add(HEADER_LEN)) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "BStackVec: block size overflows u64", + ) + }) } fn elem_offset(index: u64) -> u64 { + // `index` is always < `len` which was read from a header we wrote, so + // this multiplication cannot overflow in well-formed data. A corrupt + // on-disk `len` could cause overflow and a debug-mode panic; that is + // acceptable — corruption is not a recoverable condition here. HEADER_LEN + index * Self::elem_size() } @@ -173,7 +190,7 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { /// Reallocate the block to hold `new_cap` elements, updating `self.slice`. fn grow_to(&mut self, new_cap: u64) -> io::Result<()> { - let new_size = Self::block_size(new_cap); + let new_size = Self::block_size(new_cap)?; // SAFETY: Slice origin requirement is upheld because `self.slice` is the original allocation handle // returned by the constructor, and `realloc` returns a new slice for the same block. let new_slice = self.slice.allocator().realloc(self.slice, new_size)?; @@ -200,7 +217,7 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { /// Create an empty `BStackVec` pre-sized for at least `capacity` elements. pub fn with_capacity(capacity: u64, alloc: &'a A) -> io::Result { - let slice = alloc.alloc(Self::block_size(capacity))?; + let slice = alloc.alloc(Self::block_size(capacity)?)?; let vec = Self { slice, _phantom: PhantomData, @@ -215,7 +232,7 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { /// The resulting vec has `len == capacity == data.len()`. pub fn from_slice(data: &[T], alloc: &'a A) -> io::Result { let len = data.len() as u64; - let slice = alloc.alloc(Self::block_size(len))?; + let slice = alloc.alloc(Self::block_size(len)?)?; let vec = Self { slice, _phantom: PhantomData, @@ -280,6 +297,12 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { /// The slice covers `[16, 16 + len * size_of::())` within the block. /// It is a sub-slice and must **not** be passed to `realloc` or `dealloc`; /// use [`raw_block`](Self::raw_block) for that. + /// + /// # Panics + /// + /// Panics if the `len` read from the block header is corrupt (larger than + /// the block can hold), causing the computed end offset to exceed the + /// block's length. Corruption is not a recoverable condition here. pub fn as_slice(&self) -> io::Result> { let (len, _) = self.read_header()?; Ok(self @@ -407,9 +430,16 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { } /// Deallocate the underlying block and consume the vec. + /// + /// Preferred over `alloc.dealloc(v.into_raw_block())` as it keeps the + /// dealloc call co-located with the type. The slice origin requirement is + /// upheld because `self.slice` is always the original allocation handle + /// returned by the constructor (or a subsequent `realloc`). + /// + /// After this call the backing storage is released; no further I/O on any + /// handle derived from this vec (e.g. a prior [`raw_block`](Self::raw_block) + /// copy) is valid. pub fn dealloc(self) -> io::Result<()> { - // Slice origin requirement is upheld because `self.slice` is the original allocation handle - // returned by the constructor, and `dealloc` is only called on `self.slice`. self.slice.allocator().dealloc(self.slice) } } @@ -463,7 +493,7 @@ impl<'b, 'a: 'b, T: Copy, A: BStackSliceAllocator> Iterator for BStackVecIter<'b mod tests { use super::*; use crate::BStack; - use crate::alloc::LinearBStackAllocator; + use crate::alloc::{BStackAllocator, LinearBStackAllocator}; use std::sync::atomic::{AtomicU64, Ordering}; // ── helpers ─────────────────────────────────────────────────────────────── @@ -867,4 +897,125 @@ mod tests { } assert_eq!(v.raw_block().len(), 16); } + + // ── integration: block_size overflow ───────────────────────────────────── + + #[test] + fn with_capacity_overflow_returns_error() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + // u64::MAX elements of u64 = u64::MAX * 8, which overflows u64. + let err = BStackVec::::with_capacity(u64::MAX, &alloc).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + } + + #[test] + fn with_capacity_overflow_zst_is_fine() { + // ZST elem_size = 0 so u64::MAX * 0 = 0; no overflow. + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let v = BStackVec::<(), _>::with_capacity(u64::MAX, &alloc).unwrap(); + assert_eq!(v.capacity().unwrap(), u64::MAX); + assert_eq!(v.raw_block().len(), 16); + } + + #[test] + fn reserve_overflow_through_grow_returns_error() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + // Start with a vec near the edge of what block_size can represent. + // After one push (cap grows to 4), request enough additional that + // len + additional overflows u64. + let mut v: BStackVec = BStackVec::new(&alloc).unwrap(); + v.push(1).unwrap(); + let err = v.reserve(u64::MAX).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + } + + // ── integration: interop with BStackSliceReader ─────────────────────────── + + #[test] + fn as_slice_readable_via_slice_reader() { + use std::io::Read; + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let v = BStackVec::from_slice(&[0x0A0Bu16, 0x0C0D, 0x0E0F], &alloc).unwrap(); + + let s = v.as_slice().unwrap(); + let mut reader = s.reader(); + let mut buf = [0u8; 6]; + reader.read_exact(&mut buf).unwrap(); + + // Elements are stored in the native repr of u16 (little-endian on all + // supported platforms), laid out consecutively starting at byte 0 of + // the element region. + let expected: Vec = [0x0A0Bu16, 0x0C0D, 0x0E0F] + .iter() + .flat_map(|x| x.to_ne_bytes()) + .collect(); + assert_eq!(&buf, expected.as_slice()); + } + + // ── integration: dealloc reclaims tail ─────────────────────────────────── + + #[test] + fn dealloc_reclaims_tail_from_linear_allocator() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + + let size_before = alloc.stack().len().unwrap(); + let mut v: BStackVec = BStackVec::new(&alloc).unwrap(); + v.push(1).unwrap(); + v.push(2).unwrap(); + let size_after_push = alloc.stack().len().unwrap(); + assert!(size_after_push > size_before); + + v.dealloc().unwrap(); + // LinearBStackAllocator::dealloc on the tail slice calls BStack::discard, + // so the stack should shrink back to its pre-allocation size. + assert_eq!(alloc.stack().len().unwrap(), size_before); + } + + // ── integration: two vecs on the same allocator ─────────────────────────── + + #[test] + fn two_vecs_on_same_allocator_do_not_interfere() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + + // Pre-size both vecs so neither triggers a realloc: LinearBStackAllocator + // can only grow the tail allocation, so interleaved pushes would fail + // if the blocks needed to move. + let mut a: BStackVec = BStackVec::with_capacity(4, &alloc).unwrap(); + let mut b: BStackVec = BStackVec::with_capacity(4, &alloc).unwrap(); + + a.push(10).unwrap(); + b.push(20).unwrap(); + a.push(11).unwrap(); + b.push(21).unwrap(); + + assert_eq!(a.len().unwrap(), 2); + assert_eq!(b.len().unwrap(), 2); + assert_eq!(a.get(0).unwrap(), Some(10u32)); + assert_eq!(a.get(1).unwrap(), Some(11u32)); + assert_eq!(b.get(0).unwrap(), Some(20u32)); + assert_eq!(b.get(1).unwrap(), Some(21u32)); + } + + // ── integration: as_slice length after mutation ─────────────────────────── + + #[test] + fn as_slice_len_tracks_vec_len() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v: BStackVec = BStackVec::new(&alloc).unwrap(); + + assert_eq!(v.as_slice().unwrap().len(), 0); + v.push(1).unwrap(); + assert_eq!(v.as_slice().unwrap().len(), size_of::() as u64); + v.push(2).unwrap(); + assert_eq!(v.as_slice().unwrap().len(), 2 * size_of::() as u64); + v.pop().unwrap(); + assert_eq!(v.as_slice().unwrap().len(), size_of::() as u64); + } } From e441a5248abea5794b17d46078aa94335a7e505c Mon Sep 17 00:00:00 2001 From: William Wu <204092015+williamwutq@users.noreply.github.com> Date: Wed, 20 May 2026 14:02:40 -0700 Subject: [PATCH 14/17] Fix table --- src/alloc/vec.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/alloc/vec.rs b/src/alloc/vec.rs index 83264d8..6259eb8 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -22,7 +22,7 @@ const HEADER_LEN: u64 = 16; /// /// ```text /// ┌──────────────────────┬──────────────────────┬────────────────────────────┐ -/// │ len (8 B, LE u64) │ cap (8 B, LE u64) │ elements: [T; cap] │ +/// │ len (8 B, LE u64) │ cap (8 B, LE u64) │ elements: [T; cap] │ /// └──────────────────────┴──────────────────────┴────────────────────────────┘ /// byte 0 byte 8 byte 16 /// ``` From 99de6a2437d8fea7aa582d42f0aa64714555363f Mon Sep 17 00:00:00 2001 From: William Wu <204092015+williamwutq@users.noreply.github.com> Date: Wed, 20 May 2026 14:14:27 -0700 Subject: [PATCH 15/17] Fix hashmap gating Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index ba6502f..2767e46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ required-features = ["alloc", "set"] [[example]] name = "hashmap" -required-features = ["alloc", "set"] +required-features = ["set"] [[example]] name = "atomic_ops" From 89e5e665750f2d3b35d9fa1ba7f4470011f8e962 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 20 May 2026 14:21:55 -0700 Subject: [PATCH 16/17] Add batching --- src/alloc/vec.rs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/alloc/vec.rs b/src/alloc/vec.rs index 6259eb8..bc35ce4 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -183,6 +183,16 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { self.slice.write_range(start, bytes) } + fn write_elems_at(&self, start_index: u64, values: &[T]) -> io::Result<()> { + let _size = size_of::(); + let start = Self::elem_offset(start_index); + // SAFETY: `values` is a valid slice of initialized `T`s; we borrow its bytes. + let bytes = unsafe { + std::slice::from_raw_parts(values.as_ptr() as *const u8, std::mem::size_of_val(values)) + }; + self.slice.write_range(start, bytes) + } + fn zero_elem_at(&self, index: u64) -> io::Result<()> { self.slice .zero_range(Self::elem_offset(index), Self::elem_size()) @@ -240,9 +250,8 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { if len > 0 { // Write len and cap; elements are written individually below. vec.write_header(len, len)?; - for (i, &item) in data.iter().enumerate() { - vec.write_elem_at(i as u64, item)?; - } + // writes all elements in one call; see write_elems_at() for safety comments + vec.write_elems_at(0, data)?; } Ok(vec) } @@ -392,9 +401,12 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { return self.truncate(new_len); } self.reserve(new_len - len)?; - for i in len..new_len { - self.write_elem_at(i, value)?; - } + self.write_elems_at( + len, + std::iter::repeat_n(value, (new_len - len) as usize) + .collect::>() + .as_slice(), + )?; self.write_len_field(new_len) } From b8f24339a949567f5cf4e76726f4919e2db71282 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 06:33:06 +0000 Subject: [PATCH 17/17] Address remaining BStackVec review comments Agent-Logs-Url: https://github.com/williamwutq/bstack/sessions/c035aec1-4511-48fa-a508-b25cdb1aeb97 Co-authored-by: williamwutq <204092015+williamwutq@users.noreply.github.com> --- CHANGELOG.md | 6 +++--- README.md | 3 ++- src/alloc/vec.rs | 54 ++++++++++++++++++++++++++++++++++++++++-------- src/lib.rs | 3 ++- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index addeee0..dbd0ca1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`BStackVec<'a, T: Copy, A: BStackSliceAllocator>`** (`alloc` + `set` features): A typed, growable vector backed by a `BStack` allocation, mirroring the core `Vec` API. The 16-byte block header stores `len` and `capacity` as little-endian `u64` values; elements follow immediately. The header is re-read from disk on every call, so the metadata is recoverable after a crash by reconstructing the handle via `BStackVec::from_raw_block`. Key design points: - **Growth**: when `push` would exceed capacity, the block is reallocated to `max(cap * 2, 4)` elements via the allocator's `realloc`. New element space is zero-initialised by `BStack::extend`. - - **Zeroing on pop**: `pop` zeros the vacated slot before decrementing `len`; `truncate` zeros all removed slots in a single `BStackSlice::zero_range` call. Deallocation zeroing is delegated to the allocator. - - **API**: `new`, `with_capacity`, `from_slice`, `unsafe from_raw_block`, `len`, `capacity`, `is_empty`, `get`, `as_slice`, `push`, `pop`, `truncate`, `clear`, `reserve`, `resize`, `iter`, `raw_block`, `into_raw_block`. + - **Zeroing on removal**: `pop` decrements `len` before zeroing the vacated slot; `truncate` writes the new `len` before zeroing removed slots in a single `BStackSlice::zero_range` call. Deallocation zeroing is delegated to the allocator. + - **API**: `new`, `with_capacity`, `from_slice`, `unsafe from_raw_block`, `len`, `capacity`, `is_empty`, `get`, `read_vec`, `as_slice`, `push`, `pop`, `truncate`, `clear`, `reserve`, `resize`, `iter`, `raw_block`, `into_raw_block`. - **Iterator**: `BStackVecIter<'b, 'a, T, A>` borrows the vec immutably for its lifetime (preventing concurrent mutation), snapshots `len` at construction, and yields `io::Result` per element read from disk on demand. ### Changed @@ -210,4 +210,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Multi-process safety via advisory `flock` on Unix - File format with 16-byte header containing magic number and committed length - Durability guarantees with `durable_sync` (fdatasync on Unix) -- Optional `set` feature for in-place payload mutation \ No newline at end of file +- Optional `set` feature for in-place payload mutation diff --git a/README.md b/README.md index d7e23cb..8860571 100644 --- a/README.md +++ b/README.md @@ -636,7 +636,8 @@ recoverable after a crash by reconstructing the handle from the raw block via #### Key behaviour - **Growth**: `push` reallocates to `max(cap × 2, 4)` elements when `len == cap`. New element space is zero-initialised by `BStack::extend`. -- **Zeroing on pop**: `pop` zeros the vacated slot before decrementing `len`. `truncate` zeros all removed slots in a single `BStackSlice::zero_range` call. Deallocation zeroing is delegated to the allocator. +- **Readback helper**: `read_vec` loads all logical elements into a Rust `Vec`. +- **Zeroing on removal**: `pop` decrements `len` before zeroing the vacated slot; `truncate` writes the new `len` before zeroing removed slots in a single `BStackSlice::zero_range` call. Deallocation zeroing is delegated to the allocator. - **Iterator**: `BStackVecIter` borrows the vec immutably for its lifetime (preventing concurrent mutation) and yields `io::Result` per element, reading from disk on demand. #### Example diff --git a/src/alloc/vec.rs b/src/alloc/vec.rs index bc35ce4..652b65d 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -73,9 +73,9 @@ const HEADER_LEN: u64 = 16; /// | Method | Step order | Crash-recovery state | /// |--------|-----------|----------------------| /// | `push` (no realloc) | write element → increment `len` | Crash after element write: element on disk but `len` not updated; slot is effectively invisible. Re-running `push` with the same value recovers correctly. | -/// | `push` (with realloc) | `realloc` → write `cap` → write element → increment `len` | Crash at any point: header re-read on next open reflects the committed state; worst case is a leaked block (with `LinearBStackAllocator`) or a stale cap value. | -/// | `pop` | read element → zero slot → decrement `len` | Crash after zero but before `len` decrement: element is zeroed on disk but `len` still counts it; `get(len-1)` returns a zero-bit `T`. Decrementing `len` on the next `pop` call removes it cleanly. | -/// | `truncate` | zero removed slots → write `len` | Crash after zeroing but before `len` write: logically removed elements are on disk as zeros; `len` still points past them. Calling `truncate` again to the same target is idempotent. | +/// | `push` (with realloc) | `realloc` → write `cap` → write element → increment `len` | Crash at any point: header re-read on next open reflects the committed state; worst case is allocator-specific intermediate metadata or a stale cap value. | +/// | `pop` | read element → decrement `len` → zero slot | Crash after `len` decrement but before zero: stale bytes may remain in the now out-of-range slot, but reads never deserialize them because they are beyond `len`. | +/// | `truncate` | write `len` → zero removed slots | Crash after `len` write but before zero: stale bytes may remain in now out-of-range slots, but reads never deserialize them because they are beyond `len`. | /// | `resize` (grow) | `reserve` → write elements → write `len` | Same as `push` repeated; elements between the old and new `len` may be partially written. | /// | `clear` | (delegates to `truncate(0)`) | See `truncate`. | /// | `reserve` | `realloc` → write `cap` | Crash between the two: cap field may reflect the old value; the block is larger than cap indicates. Harmless — the next `push` re-checks and may realloc again unnecessarily. | @@ -301,6 +301,26 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { Ok(Some(self.read_elem_at(index)?)) } + /// Read all logical elements and return them as a Rust [`Vec`]. + /// + /// Equivalent to collecting [`iter`](Self::iter), but returns a single + /// `io::Result>`. + pub fn read_vec(&self) -> io::Result> { + let (len, _) = self.read_header()?; + let len_usize = usize::try_from(len).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "BStackVec::read_vec: len does not fit usize", + ) + })?; + + let mut out = Vec::with_capacity(len_usize); + for i in 0..len { + out.push(self.read_elem_at(i)?); + } + Ok(out) + } + /// Return a [`BStackSlice`] spanning only the populated element bytes. /// /// The slice covers `[16, 16 + len * size_of::())` within the block. @@ -336,22 +356,23 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { /// Remove and return the last element, or `None` if empty. /// - /// The vacated slot is zeroed before `len` is decremented. + /// `len` is decremented before the vacated slot is zeroed. pub fn pop(&mut self) -> io::Result> { let (len, _) = self.read_header()?; if len == 0 { return Ok(None); } let value = self.read_elem_at(len - 1)?; - self.zero_elem_at(len - 1)?; self.write_len_field(len - 1)?; + self.zero_elem_at(len - 1)?; Ok(Some(value)) } /// Shorten the vec to `new_len` elements. /// - /// No-op when `new_len >= len`. Removed slots are zeroed in a single - /// [`BStackSlice::zero_range`] call; capacity is unchanged. + /// No-op when `new_len >= len`. `len` is updated first, then removed slots + /// are zeroed in a single [`BStackSlice::zero_range`] call; capacity is + /// unchanged. pub fn truncate(&mut self, new_len: u64) -> io::Result<()> { let (len, _) = self.read_header()?; if new_len >= len { @@ -359,8 +380,8 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { } let start = Self::elem_offset(new_len); let removed_bytes = (len - new_len) * Self::elem_size(); - self.slice.zero_range(start, removed_bytes)?; - self.write_len_field(new_len) + self.write_len_field(new_len)?; + self.slice.zero_range(start, removed_bytes) } /// Remove all elements without releasing the allocation. @@ -429,6 +450,12 @@ impl<'a, T: Copy, A: BStackSliceAllocator> BStackVec<'a, T, A> { /// /// This is the original allocation handle and may be passed to /// [`crate::BStackAllocator::realloc`] or [`crate::BStackAllocator::dealloc`]. + /// + /// # Invalidation + /// + /// Any call that may reallocate (`push`, `reserve`, `resize`) can invalidate + /// previously returned handles. Re-fetch with `raw_block()` after such + /// mutations. pub fn raw_block(&self) -> BStackSlice<'a, A> { self.slice } @@ -669,6 +696,15 @@ mod tests { assert_eq!(v.get(u64::MAX).unwrap(), None); } + #[test] + fn read_vec_returns_all_elements() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let src = [7u64, 11, 13, 17]; + let v = BStackVec::from_slice(&src, &alloc).unwrap(); + assert_eq!(v.read_vec().unwrap(), src); + } + // ── growth ─────────────────────────────────────────────────────────────── #[test] diff --git a/src/lib.rs b/src/lib.rs index cd09c30..9fe3e58 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -362,7 +362,8 @@ //! * [`BStackVec`]`<'a, T: Copy, A>` — a typed, growable vector backed by a //! [`BStack`] allocation (requires `alloc` + `set`). Mirrors the core //! [`Vec`] API: `new`, `with_capacity`, `from_slice`, `push`, `pop`, -//! `get`, `as_slice`, `truncate`, `clear`, `reserve`, `resize`, and `iter`. +//! `get`, `read_vec`, `as_slice`, `truncate`, `clear`, `reserve`, `resize`, +//! and `iter`. //! The block stores a 16-byte header (`len`, `cap`) followed by the element //! data; the header is re-read on every call for crash recoverability. `push` //! doubles capacity (minimum 4); `pop` zeros the vacated slot; `truncate`